diff --git a/CMakeLists.txt b/CMakeLists.txt index 12c69f8..4c17901 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,12 +82,34 @@ set(SDK_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/reaper-sdk/sdk) set(WDL_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/WDL/WDL) set(SWELL ${WDL_INC}/swell) +# --------------------------------------------------------------------------- +# 0) core/ — the Q-W1 shared pure substrate. NO REAPER, NO SWELL, NO VST3. +# json: the ONE JSON lexical layer (reader + writer) behind the five +# persisted-blob (de)serializers (bank_model / bank_book / +# view_mode_model / owned_manifest / tail_control). T2-02 / §2. +# wire: the ONE length-prefixed ext-state wire codec (putField + Cursor + +# the guarded decimal accumulate) behind provenance / +# assignment_request / sample_usage / bank_sync. T2-01(b). +# file_bytes: the ONE whole-file byte loader both artifacts link. T2-03. +# Headers are included as "core/json/json.h" etc. (rooted at src/), so the +# include paths survive the Q-W1 part-2 directory relocation unchanged. +# --------------------------------------------------------------------------- +add_library(json STATIC src/core/json/json.cpp) +target_include_directories(json PUBLIC src) + +add_library(wire STATIC src/core/wire/wire.cpp) +target_include_directories(wire PUBLIC src) + +add_library(file_bytes STATIC src/core/util/file_bytes.cpp) +target_include_directories(file_bytes PUBLIC src) + # --------------------------------------------------------------------------- # 1) Pure model library — NO REAPER, NO SWELL. Builds & tests anywhere. # The sampler's heart: Sample metadata + BankIndex (Milestone 1). # --------------------------------------------------------------------------- add_library(bank_model STATIC src/bank_model.cpp) target_include_directories(bank_model PUBLIC src) +target_link_libraries(bank_model PRIVATE json) # --------------------------------------------------------------------------- # 2) Pure peaks library — NO REAPER, NO SWELL. Waveform min/max thumbnails from @@ -142,6 +164,7 @@ target_include_directories(tab_strip PUBLIC src) # --------------------------------------------------------------------------- add_library(view_mode_model STATIC src/view_mode_model.cpp) target_include_directories(view_mode_model PUBLIC src) +target_link_libraries(view_mode_model PRIVATE json) # The pure lane-minting decision (planLaneMinting) names managed lanes via the ONE # durable-key convention in lane_keys (laneNameForMode), so the model depends on that # pure sibling. PUBLIC so every consumer (tests + module) resolves the symbol. @@ -224,6 +247,7 @@ target_include_directories(batch_capture PUBLIC src) add_library(tail_control STATIC src/tail_control.cpp) target_include_directories(tail_control PUBLIC src) target_link_libraries(tail_control PUBLIC render_settings) +target_link_libraries(tail_control PRIVATE json) # --------------------------------------------------------------------------- # 2g') Pure bank_book library — NO REAPER, NO SWELL. The multi-bank phase heart @@ -236,6 +260,7 @@ target_link_libraries(tail_control PUBLIC render_settings) add_library(bank_book STATIC src/bank_book.cpp) target_include_directories(bank_book PUBLIC src) target_link_libraries(bank_book PUBLIC bank_model) +target_link_libraries(bank_book PRIVATE json) # --------------------------------------------------------------------------- # 2g'') Pure owned_manifest library — NO REAPER, NO SWELL. The owned-file manifest @@ -248,6 +273,7 @@ target_link_libraries(bank_book PUBLIC bank_model) # --------------------------------------------------------------------------- add_library(owned_manifest STATIC src/owned_manifest.cpp) target_include_directories(owned_manifest PUBLIC src) +target_link_libraries(owned_manifest PRIVATE json) # --------------------------------------------------------------------------- # 2g''') Pure prune_reconcile library — NO REAPER, NO SWELL, NO filesystem. The @@ -323,6 +349,7 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge # --------------------------------------------------------------------------- add_library(provenance STATIC src/provenance.cpp) target_include_directories(provenance PUBLIC src) +target_link_libraries(provenance PRIVATE wire) # --------------------------------------------------------------------------- # 2j') Pure assignment_request library — NO REAPER, NO SWELL, NO VST3. The S8 ingest @@ -336,6 +363,7 @@ target_include_directories(provenance PUBLIC src) # --------------------------------------------------------------------------- add_library(assignment_request STATIC src/assignment_request.cpp) target_include_directories(assignment_request PUBLIC src) +target_link_libraries(assignment_request PRIVATE wire) # --------------------------------------------------------------------------- # 2j'') Pure sample_usage library — NO REAPER, NO SWELL, NO VST3. The pS-usage seam: @@ -349,6 +377,7 @@ target_include_directories(assignment_request PUBLIC src) # --------------------------------------------------------------------------- add_library(sample_usage STATIC src/sample_usage.cpp) target_include_directories(sample_usage PUBLIC src) +target_link_libraries(sample_usage PRIVATE wire) # --------------------------------------------------------------------------- # 2l) Pure drag_out library — NO REAPER, NO SWELL, NO OS/OLE. The Milestone 11 @@ -538,6 +567,20 @@ target_link_libraries(sampler_core PUBLIC peaks pitch_shift velocity_curve) # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- enable_testing() + +# core/ (Q-W1): the shared JSON lexical layer, wire codec, and file loader. +add_executable(json_tests tests/test_json.cpp) +target_link_libraries(json_tests PRIVATE json) +add_test(NAME json_tests COMMAND json_tests) + +add_executable(wire_tests tests/test_wire.cpp) +target_link_libraries(wire_tests PRIVATE wire) +add_test(NAME wire_tests COMMAND wire_tests) + +add_executable(file_bytes_tests tests/test_file_bytes.cpp) +target_link_libraries(file_bytes_tests PRIVATE file_bytes) +add_test(NAME file_bytes_tests COMMAND file_bytes_tests) + add_executable(bank_model_tests tests/test_bank_model.cpp) target_link_libraries(bank_model_tests PRIVATE bank_model) add_test(NAME bank_model_tests COMMAND bank_model_tests) @@ -822,6 +865,7 @@ target_link_libraries(waveform_view PUBLIC editor_geometry peaks) add_library(bank_sync STATIC src/vst/bank_sync.cpp) target_include_directories(bank_sync PUBLIC src/vst src) target_link_libraries(bank_sync PUBLIC assignment_request) +target_link_libraries(bank_sync PRIVATE wire) # browser_scroll (Phase S12) — PURE scroll-window + scrollbar-thumb + type-to-filter-search # geometry LAYERED over the S10 capture_browser: the visible-card window, thumb rect + @@ -1039,7 +1083,7 @@ add_library(reaper_reasampler MODULE src/card_drag.cpp src/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' @@ -1201,7 +1245,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") sample_map capture_paths embed_strip app_version capture_browser keyboard_strip waveform_view bank_sync browser_scroll note_entry param_slider theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit - knob_deck curve_popup master_gain sample_usage) + knob_deck curve_popup master_gain sample_usage file_bytes) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/assignment_request.cpp b/src/assignment_request.cpp index 077aeee..bbe5775 100644 --- a/src/assignment_request.cpp +++ b/src/assignment_request.cpp @@ -2,8 +2,7 @@ #include "assignment_request.h" -#include -#include +#include "core/wire/wire.h" namespace reasampler { @@ -11,114 +10,11 @@ namespace { constexpr const char* kMagic = "rsassign1"; -// Append one length-prefixed field: ':' . Mirror of -// provenance's putField so the two seams share one wire idiom. -void putField(std::string& out, const std::string& field) { - out += std::to_string(field.size()); - out += ':'; - out += field; -} - -// Cursor over the encoded string. All reads are bounds-checked; a short read fails -// the whole parse (ok_ latches false). Mirror of provenance's Cursor, trimmed to the -// three field kinds this record needs. -class Cursor { -public: - explicit Cursor(const std::string& s) : s_(s) {} - - bool ok() const { return ok_; } - bool atEnd() const { return pos_ >= s_.size(); } - - // Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or - // non-numeric length, a length that overflows SIZE_MAX, or a length that runs past - // the end. The digit count is capped at 20 (the decimal width of SIZE_MAX on a - // 64-bit host) so a crafted 200-digit length cannot accumulate past SIZE_MAX via - // repeated multiply. "never UB" promise from the header is upheld here. - bool field(std::string& out) { - if (!ok_) return false; - const std::size_t colon = s_.find(':', pos_); - if (colon == std::string::npos) return fail(); - if (colon == pos_) return fail(); // empty length token - // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. - if (colon - pos_ > 20u) return fail(); - std::size_t len = 0; - for (std::size_t i = pos_; i < colon; ++i) { - const char c = s_[i]; - if (c < '0' || c > '9') return fail(); - const std::size_t digit = static_cast(c - '0'); - // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. - if (len > (std::numeric_limits::max() - digit) / 10u) - return fail(); - len = len * 10u + digit; - } - const std::size_t start = colon + 1; - // Guard: start may equal s_.size() (empty remainder), in which case only len==0 - // is valid; start > s_.size() cannot happen (colon < s_.size() by find()). - // Use subtraction-first form to avoid start+len wrapping on a huge len. - if (start > s_.size() || len > s_.size() - start) return fail(); - out.assign(s_, start, len); - pos_ = start + len; - return true; - } - - // Reads a length-prefixed field and parses it as a signed 64-bit decimal (an - // optional leading '-'). Fails on empty, non-digit, trailing bytes, or a value - // that would overflow INT64_MAX / underflow INT64_MIN. The digit count is capped - // at 19 (the decimal width of INT64_MAX, plus 1 for the optional sign = 20 - // characters maximum) so a crafted 21-digit field cannot accumulate UB. "never UB" - // promise from the header is upheld: all arithmetic is done on positive digits - // and capped before applying the sign. - bool fieldInt64(std::int64_t& out) { - std::string f; - if (!field(f)) return false; - if (f.empty()) return fail(); - std::size_t i = 0; - bool neg = false; - if (f[0] == '-') { - neg = true; - i = 1; - if (f.size() == 1) return fail(); // bare "-" - } - // Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit - // positive value would overflow INT64_MAX; a 20-digit negative might be valid - // (INT64_MIN = -9223372036854775808) but we conservatively reject it too: the - // generation field is a unix timestamp, never near INT64 limits in practice. - if (f.size() - i > 19u) return fail(); - std::int64_t v = 0; - for (; i < f.size(); ++i) { - const char c = f[i]; - if (c < '0' || c > '9') return fail(); - const std::int64_t digit = static_cast(c - '0'); - // Overflow guard: v * 10 + digit must not exceed INT64_MAX. - if (v > (std::numeric_limits::max() - digit) / 10) - return fail(); - v = v * 10 + digit; - } - out = neg ? -v : v; - return true; - } - - // Consumes an exact literal at the cursor (the magic tag). Fails if absent. - bool literal(const char* lit) { - if (!ok_) return false; - std::size_t i = 0; - for (; lit[i] != '\0'; ++i) { - if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); - } - pos_ += i; - return true; - } - -private: - bool fail() { - ok_ = false; - return false; - } - - const std::string& s_; - std::size_t pos_ = 0; - bool ok_ = true; -}; +// The shared core/wire codec (Q-W1, T2-01b) — the same field grammar + hardening +// this file previously carried as its own Cursor copy. "never UB, never a +// partial value" is upheld in the codec. +using wire::putField; +using Cursor = wire::Cursor; } // namespace diff --git a/src/bank_book.cpp b/src/bank_book.cpp index 786e265..df5b3a9 100644 --- a/src/bank_book.cpp +++ b/src/bank_book.cpp @@ -1,14 +1,14 @@ #include "bank_book.h" #include -#include #include +#include "core/json/json.h" + // bank_book implementation. // -// JSON is hand-rolled and self-contained, matching the house style of bank_model -// and view_mode_model (brief: keep the pure core dependency-free — no third-party -// JSON lib). The book blob nests one bank object per bank, each carrying that +// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model +// and view_mode_model. The book blob nests one bank object per bank, each carrying that // bank's BankIndex serialized by bank_model's OWN writer (BankIndex::serialize), // so per-bank sample serialization stays owned by bank_model and is not duplicated // here. The book writer emits the bank envelope (id / displayName / ordinal) plus a @@ -138,7 +138,7 @@ SlotMap SlotMap::fromEntries(const std::vector>& pai } // SlotMap::serialize is defined in the JSON writer section below (it reuses the -// file-local ObjWriter / intToStr helpers). +// shared core/json emit helpers). // --------------------------------------------------------------------------- // BankBook — construction + bank lookup @@ -550,64 +550,10 @@ std::vector BankBook::referencedPaths() const { namespace { -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -std::string intToStr(int v) { - char buf[16]; - std::snprintf(buf, sizeof(buf), "%d", v); - return buf; -} - -class ObjWriter { -public: - explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } - ~ObjWriter() { out_ += '}'; } - - void keyRaw(const char* key, const std::string& rawValue) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - out_ += rawValue; - } - void keyStr(const char* key, const std::string& value) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - writeEscaped(out_, value); - } - void keyBegin(const char* key) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - } - -private: - void sep() { if (first_) first_ = false; else out_ += ','; } - std::string& out_; - bool first_ = true; -}; +// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the +// prior file-local writer carried, so the emitted blob is byte-identical. +std::string intToStr(int v) { return json::numToStr(v); } +using ObjWriter = json::Writer; } // namespace @@ -660,223 +606,38 @@ std::string BankBook::serialize() const { namespace { -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} +// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1). +// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot}, +// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive +// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root +// blob, distinguishing the legacy shape (a bare bank_index object: has +// "samples", no "banks") from the book shape (has "banks"): a legacy blob +// yields a single pool bank carrying the migrated index and an empty active id +// (⇒ pool). The member deserialize() adopts the result (ordinal normalize + +// active resolve). +bool parseSlots(json::Reader& r, std::vector>& out); - // Parses a book blob into a bank set + active id. On success fills the out-params - // and returns true. Distinguishes the legacy shape (a bare bank_index object: has - // "samples", no "banks") from the book shape (has "banks"): a legacy blob yields a - // single pool bank carrying the migrated index and an empty active id (⇒ pool). The - // member deserialize() adopts the result (ordinal normalize + active resolve). - bool parseBook(std::vector& banks, std::string& activeBank); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - - bool parseString(std::string& out); - bool parseInt(int& out); - bool parseKey(std::string& key); - bool skipValue(); - // Captures the raw source text of one JSON value (object / array / string / - // scalar) verbatim, so a nested BankIndex blob can be handed to its own parser. - bool captureValue(std::string& raw); - - bool parseBank(Bank& out); - // Parses the "slots" array ([{id, slot}, ...]) into (id, slot) pairs. An empty - // array is valid (an empty bank). Malformed structure fails the whole parse; the - // pair-level defensive repair (dupes/conflicts) lives in SlotMap::fromEntries. - bool parseSlots(std::vector>& out); -}; - -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - unsigned int hi = 0; - if (!readHex4(hi)) return false; - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; // unpaired low surrogate - } - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated -} - -bool Parser::parseInt(int& out) { - skipWs(); - std::size_t start = pos_; - if (!eof() && (s_[pos_] == '-' || s_[pos_] == '+')) ++pos_; - std::size_t digitsStart = pos_; - while (!eof() && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_; - if (pos_ == digitsStart) return false; // no digits - long v = 0; - try { - v = std::stol(s_.substr(start, pos_ - start)); - } catch (...) { - return false; // out of long range → malformed - } - if (v < INT_MIN || v > INT_MAX) return false; - out = static_cast(v); - return true; -} - -bool Parser::parseKey(std::string& key) { - if (!parseString(key)) return false; - if (!consume(':')) return false; - return true; -} - -bool Parser::skipValue() { - std::string raw; - return captureValue(raw); -} - -// Records the raw source span of one JSON value starting at the current position -// (after whitespace) so it can be re-parsed by a nested parser. Handles nested -// objects/arrays with string-aware brace matching (braces inside strings ignored). -bool Parser::captureValue(std::string& raw) { - skipWs(); - if (eof()) return false; - std::size_t start = pos_; - char c = s_[pos_]; - if (c == '"') { - std::string tmp; - if (!parseString(tmp)) return false; - raw.assign(s_, start, pos_ - start); - return true; - } - if (c == '{' || c == '[') { - char open = c, close = (c == '{') ? '}' : ']'; - ++pos_; - int depth = 1; - while (!eof() && depth > 0) { - char d = s_[pos_]; - if (d == '"') { - std::string tmp; - if (!parseString(tmp)) return false; // advances past the string - continue; - } - if (d == open) ++depth; - else if (d == close) --depth; - ++pos_; - } - if (depth != 0) return false; - raw.assign(s_, start, pos_ - start); - return true; - } - // bare scalar (number / true / false / null) - while (!eof()) { - char d = s_[pos_]; - if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' || - d == '\n' || d == '\r') - break; - ++pos_; - } - if (pos_ == start) return false; - raw.assign(s_, start, pos_ - start); - return true; -} - -bool Parser::parseBank(Bank& b) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return false; // a bank object must at least carry an id +bool parseBank(json::Reader& r, Bank& b) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return false; // a bank object must at least carry an id bool haveId = false; bool haveIndex = false; do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "id") { - if (!parseString(b.id)) return false; + if (!r.parseString(b.id)) return false; haveId = true; } else if (key == "displayName") { - if (!parseString(b.displayName)) return false; + if (!r.parseString(b.displayName)) return false; } else if (key == "ordinal") { - if (!parseInt(b.ordinal)) return false; + if (!r.parseInt(b.ordinal)) return false; } else if (key == "index") { std::string raw; - if (!captureValue(raw)) return false; + if (!r.captureValue(raw)) return false; auto idx = BankIndex::deserialize(raw); if (!idx) return false; // a malformed nested index fails the whole parse b.index = std::move(*idx); @@ -886,49 +647,50 @@ bool Parser::parseBank(Bank& b) { // nothing because the key never appears); when present it drives the // bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. std::vector> pairs; - if (!parseSlots(pairs)) return false; + if (!parseSlots(r, pairs)) return false; b.slots = SlotMap::fromEntries(pairs); } else { - if (!skipValue()) return false; // forward-compat unknown keys + if (!r.skipValue()) return false; // forward-compat unknown keys } - } while (consume(',')); + } while (r.consume(',')); - if (!consume('}')) return false; + if (!r.consume('}')) return false; if (!haveId || b.id.empty()) return false; // id keys the registry if (!haveIndex) return false; // every bank persists its index return true; } -bool Parser::parseSlots(std::vector>& out) { +bool parseSlots(json::Reader& r, std::vector>& out) { out.clear(); - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; // empty slot array — a bank with no positions yet + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; // empty slot array — a bank with no positions yet do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string id; int slot = 0; bool haveId = false, haveSlot = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "id") { if (!parseString(id)) return false; haveId = true; } - else if (k == "slot") { if (!parseInt(slot)) return false; haveSlot = true; } - else { if (!skipValue()) return false; } // forward-compat - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "id") { if (!r.parseString(id)) return false; haveId = true; } + else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; } + else { if (!r.skipValue()) return false; } // forward-compat + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveId || !haveSlot) return false; // a slot entry needs both out.emplace_back(std::move(id), slot); - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseBook(std::vector& banks, std::string& activeBank) { +bool parseBook(json::Reader& r, const std::string& raw, std::vector& banks, + std::string& activeBank) { banks.clear(); activeBank.clear(); - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return false; // an empty object is neither shape → malformed + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return false; // an empty object is neither shape → malformed // Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a // "samples" key with no "banks" ⇒ legacy shape (promote into the pool). @@ -938,41 +700,41 @@ bool Parser::parseBook(std::vector& banks, std::string& activeBank) { do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "banks") { sawBanks = true; - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { do { Bank b; - if (!parseBank(b)) return false; + if (!parseBank(r, b)) return false; parsedBanks.push_back(std::move(b)); - } while (consume(',')); - if (!consume(']')) return false; + } while (r.consume(',')); + if (!r.consume(']')) return false; } } else if (key == "activeBank") { - if (!parseString(activeBank)) return false; + if (!r.parseString(activeBank)) return false; } else if (key == "samples") { // Legacy marker. The legacy index is re-parsed from the whole input below // (BankIndex::deserialize owns that shape); here we only skip the value to // keep the scan well-formed and note that we saw it. sawSamples = true; - if (!skipValue()) return false; + if (!r.skipValue()) return false; } else { - if (!skipValue()) return false; // version, or unknown + if (!r.skipValue()) return false; // version, or unknown } - } while (consume(',')); + } while (r.consume(',')); - if (!consume('}')) return false; - skipWs(); - if (!eof()) return false; // trailing garbage + if (!r.consume('}')) return false; + r.skipWs(); + if (!r.eof()) return false; // trailing garbage // --- Legacy migration: a bare bank_index (samples, no banks) → pool. --- if (!sawBanks) { if (!sawSamples) return false; // neither shape's marker → malformed - auto legacy = BankIndex::deserialize(s_); + auto legacy = BankIndex::deserialize(raw); if (!legacy) return false; Bank pool; pool.id = kPoolBankId; @@ -1059,11 +821,11 @@ void BankBook::adoptBanks(std::vector&& banks, const std::string& activeBa activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId); } -std::optional BankBook::deserialize(const std::string& json) { +std::optional BankBook::deserialize(const std::string& blob) { std::vector banks; std::string activeBank; - Parser p(json); - if (!p.parseBook(banks, activeBank)) return std::nullopt; + json::Reader r(blob); + if (!parseBook(r, blob, banks, activeBank)) return std::nullopt; BankBook book; book.adoptBanks(std::move(banks), activeBank); diff --git a/src/bank_model.cpp b/src/bank_model.cpp index a5286ec..07f92ce 100644 --- a/src/bank_model.cpp +++ b/src/bank_model.cpp @@ -1,17 +1,15 @@ #include "bank_model.h" #include -#include -#include -#include -#include + +#include "core/json/json.h" // bank_model implementation. // -// JSON is hand-rolled and self-contained (brief: keep the pure core -// dependency-free — no third-party JSON lib, no WDL coupling). The field set is -// a flat struct of primitives, strings, one enum, a small string array, and a -// few optionals, so a compact writer + recursive-descent parser is the simplest +// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer, +// no per-module Parser copy). The field set is a flat struct of primitives, +// strings, one enum, a small string array, and a few optionals, so a compact +// writer + recursive-descent DOMAIN parser over json::Reader is the simplest // thing that works. Doubles are emitted with 17 significant digits (%.17g), the // shortest form that round-trips every IEEE-754 double exactly, so the // deserialize(serialize(x)) == x invariant holds bit-for-bit. @@ -147,84 +145,10 @@ std::vector BankIndex::byTier(Tier tier) const { namespace { -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -std::string numToStr(double v) { - char buf[32]; - std::snprintf(buf, sizeof(buf), "%.17g", v); - return buf; -} - -std::string numToStr(std::int64_t v) { - char buf[32]; - std::snprintf(buf, sizeof(buf), "%lld", static_cast(v)); - return buf; -} - -std::string numToStr(int v) { return numToStr(static_cast(v)); } - -class ObjWriter { -public: - explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } - ~ObjWriter() { out_ += '}'; } - - void keyRaw(const char* key, const std::string& rawValue) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - out_ += rawValue; - } - void keyStr(const char* key, const std::string& value) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - writeEscaped(out_, value); - } - // Begin a nested value; caller writes the value immediately after. - void keyBegin(const char* key) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - } - -private: - void sep() { - if (first_) first_ = false; else out_ += ','; - } - std::string& out_; - bool first_ = true; -}; - -void writeStringArray(std::string& out, const std::vector& v) { - out += '['; - for (std::size_t i = 0; i < v.size(); ++i) { - if (i) out += ','; - writeEscaped(out, v[i]); - } - out += ']'; -} +using json::numToStr; +using json::writeEscaped; +using json::writeStringArray; +using ObjWriter = json::Writer; void writeSample(std::string& out, const Sample& s) { ObjWriter w(out); @@ -317,346 +241,116 @@ std::string BankIndex::serialize() const { } // --------------------------------------------------------------------------- -// JSON parser (recursive descent). Returns false on any malformed input; never -// reads out of bounds. Only supports the subset our writer emits. +// JSON parser (recursive descent over the shared json::Reader). Returns false +// on any malformed input; never reads out of bounds. Only supports the subset +// our writer emits. The lexical layer (strings, numbers, skip) lives in +// core/json; only the Sample/index DOMAIN grammar lives here. // --------------------------------------------------------------------------- namespace { -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} - - bool parseIndex(BankIndex& out); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - char peek() const { return s_[pos_]; } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - - bool parseString(std::string& out); - bool parseRawScalar(std::string& out); // number / true / false / null token - bool parseDouble(double& out); - bool parseInt64(std::int64_t& out); - bool parseInt(int& out); - bool parseBool(bool& out); - bool expectNullOr(bool& wasNull); // peeks for `null`; consumes if present - - bool parseSample(Sample& out); - bool parseKey(std::string& key); // an object member key + ':' - bool skipValue(); // for forward-compat unknown keys -}; - -// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX -// for control chars). Positioned at the opening quote after whitespace. -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - // Decode a \uXXXX escape to its code point. - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - - unsigned int hi = 0; - if (!readHex4(hi)) return false; - - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - // High surrogate — must be followed by \uDC00–\uDFFF. - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; // unpaired low surrogate — malformed - } - - // Encode codePoint as UTF-8. - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated string -} - -// Reads a bare token (number, true, false, null) up to the next structural char. -bool Parser::parseRawScalar(std::string& out) { - skipWs(); - std::size_t start = pos_; - while (!eof()) { - char c = s_[pos_]; - if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || - c == '\n' || c == '\r') - break; - ++pos_; - } - if (pos_ == start) return false; - out.assign(s_, start, pos_ - start); - return true; -} - -bool Parser::parseDouble(double& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - const char* b = tok.c_str(); - char* end = nullptr; - errno = 0; - double v = std::strtod(b, &end); - if (end != b + tok.size()) return false; - if (errno == ERANGE) return false; // overflow / underflow → malformed - out = v; - return true; -} - -bool Parser::parseInt64(std::int64_t& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - const char* b = tok.c_str(); - char* end = nullptr; - errno = 0; - long long v = std::strtoll(b, &end, 10); - if (end != b + tok.size()) return false; - if (errno == ERANGE) return false; // overflow → malformed - out = static_cast(v); - return true; -} - -bool Parser::parseInt(int& out) { - std::int64_t v = 0; - if (!parseInt64(v)) return false; - out = static_cast(v); - return true; -} - -bool Parser::parseBool(bool& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - if (tok == "true") { out = true; return true; } - if (tok == "false") { out = false; return true; } - return false; -} - -// If the next value is the `null` token, consumes it and sets wasNull=true. -// Otherwise leaves the position untouched and sets wasNull=false. Returns false -// only on eof. -bool Parser::expectNullOr(bool& wasNull) { - skipWs(); - if (eof()) return false; - if (s_.compare(pos_, 4, "null") == 0) { - pos_ += 4; - wasNull = true; - } else { - wasNull = false; - } - return true; -} - -bool Parser::parseKey(std::string& key) { - if (!parseString(key)) return false; - if (!consume(':')) return false; - return true; -} - -// Skips one JSON value (object / array / string / scalar) for forward-compat -// with keys we don't recognize. Assumes position is at the start of the value. -bool Parser::skipValue() { - skipWs(); - if (eof()) return false; - char c = s_[pos_]; - if (c == '"') { - std::string tmp; - return parseString(tmp); - } - if (c == '{' || c == '[') { - char open = c, close = (c == '{') ? '}' : ']'; - ++pos_; - int depth = 1; - while (!eof() && depth > 0) { - char d = s_[pos_]; - if (d == '"') { - std::string tmp; - if (!parseString(tmp)) return false; - continue; - } - if (d == open) ++depth; - else if (d == close) --depth; - ++pos_; - } - return depth == 0; - } - std::string tmp; - return parseRawScalar(tmp); -} - -bool Parser::parseSample(Sample& s) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // empty object (shouldn't happen, but valid) +bool parseSample(json::Reader& r, Sample& s) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // empty object (shouldn't happen, but valid) do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "id") { - if (!parseString(s.id)) return false; + if (!r.parseString(s.id)) return false; } else if (key == "displayName") { - if (!parseString(s.displayName)) return false; + if (!r.parseString(s.displayName)) return false; } else if (key == "relativePath") { - if (!parseString(s.relativePath)) return false; + if (!r.parseString(s.relativePath)) return false; } else if (key == "sourceMode") { int v = 0; - if (!parseInt(v)) return false; + if (!r.parseInt(v)) return false; // Valid range: MasterMix(0) .. Realtime(5). if (v < static_cast(SourceMode::MasterMix) || v > static_cast(SourceMode::Realtime)) return false; s.sourceMode = static_cast(v); } else if (key == "sourceRange") { - if (!consume('{')) return false; + if (!r.consume('{')) return false; do { std::string rk; - if (!parseKey(rk)) return false; + if (!r.parseKey(rk)) return false; double dv = 0.0; - if (!parseDouble(dv)) return false; + if (!r.parseDouble(dv)) return false; if (rk == "startSeconds") s.sourceRange.startSeconds = dv; else if (rk == "endSeconds") s.sourceRange.endSeconds = dv; else if (rk == "startPpq") s.sourceRange.startPpq = dv; else if (rk == "endPpq") s.sourceRange.endPpq = dv; - } while (consume(',')); - if (!consume('}')) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; } else if (key == "trackGuids") { - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { do { std::string g; - if (!parseString(g)) return false; + if (!r.parseString(g)) return false; s.trackGuids.push_back(g); - } while (consume(',')); - if (!consume(']')) return false; + } while (r.consume(',')); + if (!r.consume(']')) return false; } } else if (key == "wetDry") { - if (!parseDouble(s.wetDry)) return false; + if (!r.parseDouble(s.wetDry)) return false; } else if (key == "channelCount") { - if (!parseInt(s.channelCount)) return false; + if (!r.parseInt(s.channelCount)) return false; } else if (key == "sampleRate") { - if (!parseInt(s.sampleRate)) return false; + if (!r.parseInt(s.sampleRate)) return false; } else if (key == "lengthSeconds") { - if (!parseDouble(s.lengthSeconds)) return false; + if (!r.parseDouble(s.lengthSeconds)) return false; } else if (key == "lengthBeats") { - if (!parseDouble(s.lengthBeats)) return false; + if (!r.parseDouble(s.lengthBeats)) return false; } else if (key == "captureTempo") { - if (!parseDouble(s.captureTempo)) return false; + if (!r.parseDouble(s.captureTempo)) return false; } else if (key == "captureTimeSigNum") { - if (!parseInt(s.captureTimeSigNum)) return false; + if (!r.parseInt(s.captureTimeSigNum)) return false; } else if (key == "captureTimeSigDenom") { - if (!parseInt(s.captureTimeSigDenom)) return false; + if (!r.parseInt(s.captureTimeSigDenom)) return false; } else if (key == "key") { bool wasNull = false; - if (!expectNullOr(wasNull)) return false; + if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.key.reset(); } else { std::string k; - if (!parseString(k)) return false; + if (!r.parseString(k)) return false; s.key = k; } } else if (key == "rootNote") { bool wasNull = false; - if (!expectNullOr(wasNull)) return false; + if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.rootNote.reset(); } else { int v = 0; - if (!parseInt(v)) return false; + if (!r.parseInt(v)) return false; // Valid MIDI note range: 0..127 inclusive (boundaries valid). if (v < 0 || v > 127) return false; s.rootNote = v; } } else if (key == "loop") { bool wasNull = false; - if (!expectNullOr(wasNull)) return false; + if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.loop.reset(); } else { - if (!consume('{')) return false; + if (!r.consume('{')) return false; LoopPoints lp; do { std::string lk; - if (!parseKey(lk)) return false; + if (!r.parseKey(lk)) return false; std::int64_t lv = 0; - if (!parseInt64(lv)) return false; + if (!r.parseInt64(lv)) return false; if (lk == "start") lp.start = lv; else if (lk == "end") lp.end = lv; - } while (consume(',')); - if (!consume('}')) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; // Invariant: 0 <= start <= end. start == end is a valid zero-length // marker; a negative index or start > end is malformed, not silently // clamped (mirrors the enum-range rejection above). @@ -664,89 +358,89 @@ bool Parser::parseSample(Sample& s) { s.loop = lp; } } else if (key == "levels") { - if (!consume('{')) return false; + if (!r.consume('{')) return false; do { std::string lk; - if (!parseKey(lk)) return false; + if (!r.parseKey(lk)) return false; double dv = 0.0; - if (!parseDouble(dv)) return false; + if (!r.parseDouble(dv)) return false; if (lk == "peakDb") s.levels.peakDb = dv; else if (lk == "rmsDb") s.levels.rmsDb = dv; else if (lk == "lufs") s.levels.lufs = dv; - } while (consume(',')); - if (!consume('}')) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; } else if (key == "clipped") { - if (!parseBool(s.clipped)) return false; + if (!r.parseBool(s.clipped)) return false; } else if (key == "tier") { int v = 0; - if (!parseInt(v)) return false; + if (!r.parseInt(v)) return false; // Valid range: Scratch(0) .. Archive(1). if (v < static_cast(Tier::Scratch) || v > static_cast(Tier::Archive)) return false; s.tier = static_cast(v); } else if (key == "contentHash") { - if (!parseString(s.contentHash)) return false; + if (!r.parseString(s.contentHash)) return false; } else if (key == "provenance") { bool wasNull = false; - if (!expectNullOr(wasNull)) return false; + if (!r.expectNullOr(wasNull)) return false; if (wasNull) { s.provenance.reset(); } else { - if (!consume('{')) return false; + if (!r.consume('{')) return false; Provenance p; do { std::string pk; - if (!parseKey(pk)) return false; + if (!r.parseKey(pk)) return false; std::string pv; - if (!parseString(pv)) return false; + if (!r.parseString(pv)) return false; if (pk == "parentSampleId") p.parentSampleId = pv; else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv; - } while (consume(',')); - if (!consume('}')) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; s.provenance = p; } } else if (key == "createdTimestamp") { - if (!parseInt64(s.createdTimestamp)) return false; + if (!r.parseInt64(s.createdTimestamp)) return false; } else { - if (!skipValue()) return false; // forward-compat: ignore unknown + if (!r.skipValue()) return false; // forward-compat: ignore unknown } - } while (consume(',')); + } while (r.consume(',')); - return consume('}'); + return r.consume('}'); } -bool Parser::parseIndex(BankIndex& out) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // empty object — vacuously an empty index +bool parseIndex(json::Reader& r, BankIndex& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // empty object — vacuously an empty index std::vector parsed; do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "samples") { - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { do { Sample s; - if (!parseSample(s)) return false; + if (!parseSample(r, s)) return false; parsed.push_back(std::move(s)); - } while (consume(',')); - if (!consume(']')) return false; + } while (r.consume(',')); + if (!r.consume(']')) return false; } } else { - if (!skipValue()) return false; // version, or unknown keys + if (!r.skipValue()) return false; // version, or unknown keys } - } while (consume(',')); + } while (r.consume(',')); - if (!consume('}')) return false; + if (!r.consume('}')) return false; // Trailing garbage after the root object is malformed. - skipWs(); - if (!eof()) return false; + r.skipWs(); + if (!r.eof()) return false; // Rebuild via add() so the same invariants (relative-path, dedup) that guard // live inserts also guard deserialized data. Rejected/collapsed entries are @@ -757,10 +451,10 @@ bool Parser::parseIndex(BankIndex& out) { } // namespace -std::optional BankIndex::deserialize(const std::string& json) { +std::optional BankIndex::deserialize(const std::string& blob) { BankIndex idx; - Parser p(json); - if (!p.parseIndex(idx)) return std::nullopt; + json::Reader r(blob); + if (!parseIndex(r, idx)) return std::nullopt; return idx; } diff --git a/src/capture.cpp b/src/capture.cpp index 61c9113..b53cb46 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -43,6 +43,7 @@ #include #include "capture_paths.h" +#include "core/util/file_bytes.h" #include "render_settings.h" #define REAPERAPI_MINIMAL @@ -226,20 +227,9 @@ std::string makeUniqueTag() { return std::to_string(static_cast(now)); } -// Reads the whole file into a byte buffer. Returns an empty vector on any I/O -// failure (the caller then leaves contentHash empty — the safe, confirm-eliciting -// direction for an unreadable file). -std::vector readFileBytes(const std::string& path) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return {}; - const std::streamoff size = f.tellg(); - if (size <= 0) return {}; - std::vector bytes(static_cast(size)); - f.seekg(0); - f.read(reinterpret_cast(bytes.data()), size); - if (!f) return {}; - return bytes; -} +// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03): +// empty on any I/O failure (the caller then leaves contentHash empty — the safe, +// confirm-eliciting direction for an unreadable file). } // namespace diff --git a/src/capture_realtime.cpp b/src/capture_realtime.cpp index 4a5e16f..de084e8 100644 --- a/src/capture_realtime.cpp +++ b/src/capture_realtime.cpp @@ -79,6 +79,7 @@ #include #include "capture_paths.h" // hashBytes, deriveBankPaths +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "peaks.h" // lastFrameAboveThreshold, AudioSample #include "realtime_record.h" #include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd @@ -323,20 +324,9 @@ private: namespace { -// Reads the whole file into a byte buffer. Empty vector on any I/O failure — the -// caller treats an unreadable file as "skip the trim" (keep the untrimmed window), -// never as a corruption of the recorded audio. -std::vector readAllBytes(const std::string& path) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return {}; - const std::streamoff size = f.tellg(); - if (size <= 0) return {}; - std::vector bytes(static_cast(size)); - f.seekg(0); - f.read(reinterpret_cast(bytes.data()), size); - if (!f) return {}; - return bytes; -} +// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03): +// empty on any I/O failure — the caller treats an unreadable file as "skip the +// trim" (keep the untrimmed window), never as a corruption of the recorded audio. // Patches a little-endian uint32 into a byte buffer at `off` (the header size fields). void writeU32LE(std::vector& bytes, std::size_t off, std::uint32_t v) { @@ -371,7 +361,7 @@ double trimAutoTailInPlace(const std::string& path, double rangeEndSeconds) { constexpr double kNoTrim = -1.0; - std::vector bytes = readAllBytes(path); + std::vector bytes = readFileBytes(path); if (bytes.empty()) return kNoTrim; const reasampler::WavLayout layout = parseWavLayout(bytes); @@ -533,7 +523,7 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) { // contentHash empty — the safe, confirm-eliciting direction (bank_model treats // "" as non-participating). { - const std::vector fileBytes = readAllBytes(destPath); + const std::vector fileBytes = readFileBytes(destPath); if (!fileBytes.empty()) { result.sample.contentHash = hashWavContent(fileBytes); } diff --git a/src/core/json/json.cpp b/src/core/json/json.cpp new file mode 100644 index 0000000..ad3d6bb --- /dev/null +++ b/src/core/json/json.cpp @@ -0,0 +1,319 @@ +// core/json implementation — see json.h. The bodies are the (previously +// quintuplicated) bank_model / view_mode_model lexical layer, verbatim; any +// behavioral change here changes five persisted-blob parsers at once. + +#include "core/json/json.h" + +#include +#include +#include +#include + +namespace reasampler::json { + +// --------------------------------------------------------------------------- +// emit helpers +// --------------------------------------------------------------------------- + +void writeEscaped(std::string& out, const std::string& s) { + out += '"'; + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); + out += buf; + } else { + out += c; + } + } + } + out += '"'; +} + +std::string numToStr(double v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.17g", v); + return buf; +} + +std::string numToStr(std::int64_t v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%lld", static_cast(v)); + return buf; +} + +std::string numToStr(int v) { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%d", v); + return buf; +} + +void writeStringArray(std::string& out, const std::vector& v) { + out += '['; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i) out += ','; + writeEscaped(out, v[i]); + } + out += ']'; +} + +void writeIntArray(std::string& out, const std::vector& v) { + out += '['; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i) out += ','; + out += numToStr(v[i]); + } + out += ']'; +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +void Reader::skipWs() { + while (!eof()) { + char c = s_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; + else break; + } +} + +bool Reader::consume(char c) { + skipWs(); + if (eof() || s_[pos_] != c) return false; + ++pos_; + return true; +} + +// Parses a JSON string literal (with the escapes our writers emit, plus \uXXXX +// for control chars). Positioned before the opening quote (skips leading ws). +bool Reader::parseString(std::string& out) { + skipWs(); + if (eof() || s_[pos_] != '"') return false; + ++pos_; + out.clear(); + while (!eof()) { + char c = s_[pos_++]; + if (c == '"') return true; + if (c == '\\') { + if (eof()) return false; + char e = s_[pos_++]; + switch (e) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': { + // Decode a \uXXXX escape to its code point. + auto readHex4 = [&](unsigned int& cp) -> bool { + if (pos_ + 4 > s_.size()) return false; + cp = 0; + for (int i = 0; i < 4; ++i) { + char h = s_[pos_++]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + else return false; + } + return true; + }; + + unsigned int hi = 0; + if (!readHex4(hi)) return false; + + unsigned int codePoint = hi; + if (hi >= 0xD800 && hi <= 0xDBFF) { + // High surrogate — must be followed by \uDC00–\uDFFF. + if (pos_ + 6 > s_.size()) return false; + if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; + pos_ += 2; + unsigned int lo = 0; + if (!readHex4(lo)) return false; + if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate + codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); + } else if (hi >= 0xDC00 && hi <= 0xDFFF) { + return false; // unpaired low surrogate — malformed + } + + // Encode codePoint as UTF-8. + if (codePoint <= 0x7F) { + out += static_cast(codePoint); + } else if (codePoint <= 0x7FF) { + out += static_cast(0xC0 | (codePoint >> 6)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else if (codePoint <= 0xFFFF) { + out += static_cast(0xE0 | (codePoint >> 12)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else { + out += static_cast(0xF0 | (codePoint >> 18)); + out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } + break; + } + default: return false; + } + } else { + out += c; + } + } + return false; // unterminated string +} + +bool Reader::parseRawScalar(std::string& out) { + skipWs(); + std::size_t start = pos_; + while (!eof()) { + char c = s_[pos_]; + if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || + c == '\n' || c == '\r') + break; + ++pos_; + } + if (pos_ == start) return false; + out.assign(s_, start, pos_ - start); + return true; +} + +bool Reader::parseDouble(double& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + const char* b = tok.c_str(); + char* end = nullptr; + errno = 0; + double v = std::strtod(b, &end); + if (end != b + tok.size()) return false; + if (errno == ERANGE) return false; // overflow / underflow -> malformed + out = v; + return true; +} + +bool Reader::parseInt64(std::int64_t& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + const char* b = tok.c_str(); + char* end = nullptr; + errno = 0; + long long v = std::strtoll(b, &end, 10); + if (end != b + tok.size()) return false; + if (errno == ERANGE) return false; // overflow -> malformed + out = static_cast(v); + return true; +} + +bool Reader::parseInt(int& out) { + std::int64_t v = 0; + if (!parseInt64(v)) return false; + if (v < INT_MIN || v > INT_MAX) return false; + out = static_cast(v); + return true; +} + +bool Reader::parseBool(bool& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + if (tok == "true") { out = true; return true; } + if (tok == "false") { out = false; return true; } + return false; +} + +bool Reader::expectNullOr(bool& wasNull) { + skipWs(); + if (eof()) return false; + if (s_.compare(pos_, 4, "null") == 0) { + pos_ += 4; + wasNull = true; + } else { + wasNull = false; + } + return true; +} + +bool Reader::parseKey(std::string& key) { + if (!parseString(key)) return false; + return consume(':'); +} + +bool Reader::parseStringArray(std::vector& out) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; // empty array + do { + std::string s; + if (!parseString(s)) return false; + out.push_back(std::move(s)); + } while (consume(',')); + return consume(']'); +} + +bool Reader::parseIntArray(std::vector& out) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; + do { + int v = 0; + if (!parseInt(v)) return false; + out.push_back(v); + } while (consume(',')); + return consume(']'); +} + +bool Reader::skipValue() { + std::string raw; + return captureValue(raw); +} + +// Records the raw source span of one JSON value starting at the current position +// (after whitespace). Handles nested objects/arrays with string-aware brace +// matching (braces inside strings ignored). +bool Reader::captureValue(std::string& raw) { + skipWs(); + if (eof()) return false; + std::size_t start = pos_; + char c = s_[pos_]; + if (c == '"') { + std::string tmp; + if (!parseString(tmp)) return false; + raw.assign(s_, start, pos_ - start); + return true; + } + if (c == '{' || c == '[') { + char open = c, close = (c == '{') ? '}' : ']'; + ++pos_; + int depth = 1; + while (!eof() && depth > 0) { + char d = s_[pos_]; + if (d == '"') { + std::string tmp; + if (!parseString(tmp)) return false; // advances past the string + continue; + } + if (d == open) ++depth; + else if (d == close) --depth; + ++pos_; + } + if (depth != 0) return false; + raw.assign(s_, start, pos_ - start); + return true; + } + // bare scalar (number / true / false / null) + return parseRawScalar(raw); +} + +} // namespace reasampler::json diff --git a/src/core/json/json.h b/src/core/json/json.h new file mode 100644 index 0000000..ce1c281 --- /dev/null +++ b/src/core/json/json.h @@ -0,0 +1,149 @@ +// core/json — the ONE hand-rolled JSON lexical layer (Q-W1; audit T2-02 / §2 +// "Parser ×4"). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. +// +// This module owns the lexical half of the house JSON dialect: the escape-aware +// string literal (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), the bare +// scalar tokens, the number parses (strtod/strtoll with full-token + ERANGE +// rejection), key+':' consumption, unknown-value skipping, and the emit side +// (escaping, %.17g / %d / %lld number rendering, the scoped object writer). +// The DOMAIN grammars — which keys exist, what shape each value takes, what is +// rejected at the model boundary — stay in the consumers (bank_model, bank_book, +// view_mode_model, owned_manifest, tail_control). One lexical definition means +// the five decoders can no longer drift on tolerance or escaping. +// +// Byte-compatibility contract (load-bearing): the emit helpers reproduce the +// prior per-module writers EXACTLY — writeEscaped's escape set, %.17g for +// doubles (shortest form that round-trips every IEEE-754 double bit-for-bit), +// plain decimal for ints — so a re-serialized blob is byte-identical to what +// the pre-extraction writers produced. This was a structural dedupe, not a +// format change; persisted .rpp ext-state must not shift by a byte. + +#pragma once + +#include +#include +#include + +namespace reasampler::json { + +// --------------------------------------------------------------------------- +// emit helpers (writer side) +// --------------------------------------------------------------------------- + +// Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX +// for remaining control chars, everything else verbatim (UTF-8 passes through). +void writeEscaped(std::string& out, const std::string& s); + +// Number rendering. %.17g is the shortest form that round-trips every IEEE-754 +// double exactly, so deserialize(serialize(x)) == x holds bit-for-bit. +std::string numToStr(double v); +std::string numToStr(std::int64_t v); +std::string numToStr(int v); + +// Flat homogeneous arrays: ["a","b"] / [1,2]. Empty vector -> "[]". +void writeStringArray(std::string& out, const std::vector& v); +void writeIntArray(std::string& out, const std::vector& v); + +// Scoped object writer: appends '{' on construction and '}' on destruction, with +// comma separation handled internally. Nested values are written by keyBegin() +// followed by the caller emitting the value (e.g. a nested Writer scope or an +// array). NOTE the destructor-close means an enclosing scope must END (brace +// block) before the built string is returned — see the NRVO note in the +// consumers' serialize() implementations. +class Writer { +public: + explicit Writer(std::string& out) : out_(out) { out_ += '{'; } + ~Writer() { out_ += '}'; } + + Writer(const Writer&) = delete; + Writer& operator=(const Writer&) = delete; + + // "key": — rawValue appended verbatim (numbers, bools, null, + // pre-serialized nested blobs). + void keyRaw(const char* key, const std::string& rawValue) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + out_ += rawValue; + } + // "key":"value" — value escaped. + void keyStr(const char* key, const std::string& value) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + writeEscaped(out_, value); + } + // "key": — caller writes the value immediately after. + void keyBegin(const char* key) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + } + +private: + void sep() { + if (first_) first_ = false; else out_ += ','; + } + std::string& out_; + bool first_ = true; +}; + +// --------------------------------------------------------------------------- +// Reader — the lexical cursor (parser side) +// --------------------------------------------------------------------------- +// +// Every method returns false on malformed input and never reads out of bounds. +// Only the subset the house writers emit is supported. The reader borrows the +// input string — it must outlive the Reader. +class Reader { +public: + explicit Reader(const std::string& s) : s_(s) {} + + bool eof() const { return pos_ >= s_.size(); } + void skipWs(); + + // Consumes `c` (after whitespace). False without advancing past `c` if the + // next non-ws char differs. + bool consume(char c); + + // JSON string literal (escapes + \uXXXX incl. surrogate pairs -> UTF-8). + bool parseString(std::string& out); + + // Bare token (number / true / false / null) up to the next structural char. + bool parseRawScalar(std::string& out); + + // Numbers: full-token parse; trailing bytes or ERANGE reject. parseInt + // additionally rejects values outside [INT_MIN, INT_MAX]. + bool parseDouble(double& out); + bool parseInt64(std::int64_t& out); + bool parseInt(int& out); + + bool parseBool(bool& out); + + // Peeks for the `null` token; consumes it if present (wasNull=true), + // otherwise leaves the position untouched (wasNull=false). Returns false + // only on eof. + bool expectNullOr(bool& wasNull); + + // An object member key + ':'. + bool parseKey(std::string& key); + + // Homogeneous arrays. Appends to `out`; empty array is valid. + bool parseStringArray(std::vector& out); + bool parseIntArray(std::vector& out); + + // Skips one value of any shape (string / object / array / bare scalar) — + // forward-compat for unknown keys. + bool skipValue(); + + // Captures the raw source text of one value verbatim (string-aware brace + // matching), so a nested blob can be handed to its own parser — the + // bank_book -> BankIndex::deserialize seam. + bool captureValue(std::string& raw); + +private: + const std::string& s_; + std::size_t pos_ = 0; +}; + +} // namespace reasampler::json diff --git a/src/core/util/file_bytes.cpp b/src/core/util/file_bytes.cpp new file mode 100644 index 0000000..09962e2 --- /dev/null +++ b/src/core/util/file_bytes.cpp @@ -0,0 +1,21 @@ +// core/util/file_bytes implementation — see file_bytes.h. + +#include "core/util/file_bytes.h" + +#include + +namespace reasampler { + +std::vector readFileBytes(const std::string& path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return {}; + const std::streamoff size = f.tellg(); + if (size <= 0) return {}; + std::vector bytes(static_cast(size)); + f.seekg(0); + f.read(reinterpret_cast(bytes.data()), size); + if (!f) return {}; + return bytes; +} + +} // namespace reasampler diff --git a/src/core/util/file_bytes.h b/src/core/util/file_bytes.h new file mode 100644 index 0000000..b1ee4bf --- /dev/null +++ b/src/core/util/file_bytes.h @@ -0,0 +1,19 @@ +// core/util/file_bytes — the ONE whole-file byte loader (Q-W1; audit T2-03). +// Pure standard library — NO REAPER, NO SWELL, NO VST3 — but it does blocking +// file I/O: NEVER call it on the audio thread (off-thread only, the same rule +// every prior hand-rolled copy carried). Linked by both artifacts. + +#pragma once + +#include +#include +#include + +namespace reasampler { + +// Reads the whole file at `path` into a byte buffer. Empty on ANY failure — +// unopenable, empty file, or short read — so the caller has exactly one +// "nothing to work with" branch. +std::vector readFileBytes(const std::string& path); + +} // namespace reasampler diff --git a/src/core/wire/wire.cpp b/src/core/wire/wire.cpp new file mode 100644 index 0000000..80c08b4 --- /dev/null +++ b/src/core/wire/wire.cpp @@ -0,0 +1,134 @@ +// core/wire implementation — see wire.h. The bodies are the hardened +// assignment_request / sample_usage / provenance (post Q-W0 T2-01a backport) +// cursor, unified; any behavioral change here changes every ext-state wire +// seam at once. + +#include "core/wire/wire.h" + +#include +#include + +namespace reasampler::wire { + +void putField(std::string& out, const std::string& field) { + out += std::to_string(field.size()); + out += ':'; + out += field; +} + +bool parseUnsignedDecimal(const std::string& s, std::int64_t& out) { + if (s.empty()) return false; + std::int64_t value = 0; + constexpr std::int64_t kMax = std::numeric_limits::max(); + for (const char c : s) { + if (c < '0' || c > '9') return false; // any non-digit -> reject whole + const int digit = c - '0'; + // Guard value*10 + digit against overflow before performing it. + if (value > (kMax - digit) / 10) return false; + value = value * 10 + digit; + } + out = value; + return true; +} + +bool Cursor::literal(const char* lit) { + if (!ok_) return false; + std::size_t i = 0; + for (; lit[i] != '\0'; ++i) { + if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); + } + pos_ += i; + return true; +} + +bool Cursor::field(std::string& out) { + if (!ok_) return false; + const std::size_t colon = s_.find(':', pos_); + if (colon == std::string::npos) return fail(); + if (colon == pos_) return fail(); // empty length token + // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. + if (colon - pos_ > 20u) return fail(); + std::size_t len = 0; + for (std::size_t i = pos_; i < colon; ++i) { + const char c = s_[i]; + if (c < '0' || c > '9') return fail(); + const std::size_t digit = static_cast(c - '0'); + // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. + if (len > (std::numeric_limits::max() - digit) / 10u) + return fail(); + len = len * 10u + digit; + } + const std::size_t start = colon + 1; + // Subtraction-first form: start + len cannot wrap on a huge len. + if (start > s_.size() || len > s_.size() - start) return fail(); + out.assign(s_, start, len); + pos_ = start + len; + return true; +} + +bool Cursor::fieldInt64(std::int64_t& out) { + std::string f; + if (!field(f)) return false; + if (f.empty()) return fail(); + std::size_t i = 0; + bool neg = false; + if (f[0] == '-') { + neg = true; + i = 1; + if (f.size() == 1) return fail(); // bare "-" + } + // Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit + // positive value would overflow INT64_MAX; a 20-digit negative might be valid + // (INT64_MIN) but is conservatively rejected too — see header. + if (f.size() - i > 19u) return fail(); + std::int64_t v = 0; + for (; i < f.size(); ++i) { + const char c = f[i]; + if (c < '0' || c > '9') return fail(); + const std::int64_t digit = static_cast(c - '0'); + // Overflow guard: v * 10 + digit must not exceed INT64_MAX. + if (v > (std::numeric_limits::max() - digit) / 10) + return fail(); + v = v * 10 + digit; + } + out = neg ? -v : v; + return true; +} + +bool Cursor::fieldInt(int& out) { + std::int64_t v = 0; + if (!fieldInt64(v)) return false; + if (v < std::numeric_limits::min() || v > std::numeric_limits::max()) + return fail(); + out = static_cast(v); + return true; +} + +bool Cursor::fieldSizeT(std::size_t& out) { + std::string f; + if (!field(f)) return false; + if (f.empty() || f.size() > 20u) return fail(); + std::size_t v = 0; + for (const char c : f) { + if (c < '0' || c > '9') return fail(); + const std::size_t digit = static_cast(c - '0'); + if (v > (std::numeric_limits::max() - digit) / 10u) + return fail(); + v = v * 10u + digit; + } + out = v; + return true; +} + +bool Cursor::fieldDouble(double& out) { + std::string f; + if (!field(f)) return false; + const char* b = f.c_str(); + char* end = nullptr; + double v = std::strtod(b, &end); + if (end != b + f.size()) return fail(); + out = v; + return true; +} + +} // namespace reasampler::wire diff --git a/src/core/wire/wire.h b/src/core/wire/wire.h new file mode 100644 index 0000000..9cdde10 --- /dev/null +++ b/src/core/wire/wire.h @@ -0,0 +1,88 @@ +// core/wire — the ONE length-prefixed ext-state wire codec (Q-W1; audit +// T2-01(b)). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. +// +// The `':'` field grammar ("one grammar across every +// ext-state seam") was previously implemented as three near-identical +// putField + Cursor copies (provenance / assignment_request / sample_usage) +// plus a fourth guarded decimal accumulate (bank_sync::parseBankGeneration) — +// and the copies drifted on the hardening. This is the single survivor, +// carrying the FULL hardening everywhere: +// - length digit-run capped at 20 (SIZE_MAX's decimal width) so a crafted +// digit run cannot accumulate past SIZE_MAX via repeated multiply; +// - overflow guard on every accumulate (multiply+add checked BEFORE applied); +// - subtraction-first bounds check so a huge len cannot wrap `start + len`; +// - fieldInt/fieldInt64 parse sign+digits manually with an INT64 overflow +// guard and an int range check — an out-of-range field FAILS the parse +// (closing the strtol errno/range gap the provenance copy carried). +// +// Wire formats on disk / ext-state are FROZEN: encode is byte-identical to the +// pre-collapse writers (std::to_string length + ':' + bytes), decode is +// tolerant-identical for every value a house writer can emit. "Never UB, never +// a partial value" is the parse-integrity promise. + +#pragma once + +#include +#include +#include + +namespace reasampler::wire { + +// Append one length-prefixed field: ':' +void putField(std::string& out, const std::string& field); + +// Whole-string, non-negative decimal parse WITHOUT exceptions or locale +// surprises (the bank_sync generation-stamp core). False on empty, any +// non-digit (incl. a leading '+'/'-'), or overflow past INT64_MAX; the +// accumulate is overflow-guarded so a pathologically long digit run can never +// wrap into a bogus small value. +bool parseUnsignedDecimal(const std::string& s, std::int64_t& out); + +// Bounds-checked cursor over an encoded string. All reads are bounds-checked; +// any short read fails the whole parse (ok_ latches false — every subsequent +// read also fails, so a caller may check ok() once at the end). +class Cursor { +public: + explicit Cursor(const std::string& s) : s_(s) {} + + bool ok() const { return ok_; } + bool atEnd() const { return pos_ >= s_.size(); } + + // Consumes an exact literal at the cursor (the magic tag). Fails if absent. + bool literal(const char* lit); + + // Reads one length-prefixed field into `out`. Fails on a missing ':', an + // empty or non-numeric length, a length that would overflow SIZE_MAX, or a + // length that runs past the end. + bool field(std::string& out); + + // Length-prefixed signed 64-bit decimal (optional leading '-'). Digit run + // capped at 19 (INT64_MAX's decimal width); overflow fails the parse. A + // 20-digit negative (only INT64_MIN itself) is conservatively rejected — + // house writers emit generation timestamps and small enums, never that. + bool fieldInt64(std::int64_t& out); + + // fieldInt64 narrowed to int; a value outside [INT_MIN, INT_MAX] FAILS the + // parse (the fixed form of the provenance copy's silent strtol narrowing). + bool fieldInt(int& out); + + // Length-prefixed unsigned decimal (element counts). Digit run capped at + // 20; overflow-guarded accumulate. Callers still apply their own + // count-vs-wire-size sanity bound BEFORE any reserve() on the result. + bool fieldSizeT(std::size_t& out); + + // Length-prefixed %.17g double. Full-token strtod; trailing bytes fail. + // Deliberately NO errno/ERANGE rejection: the writers emit %.17g of live + // doubles (incl. "inf"), and those must decode back — same accept set as + // every prior copy. + bool fieldDouble(double& out); + +private: + bool fail() { ok_ = false; return false; } + + const std::string& s_; + std::size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace reasampler::wire diff --git a/src/ingest.cpp b/src/ingest.cpp index 9dd4395..376a8f5 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -22,6 +22,7 @@ #include "bank_model.h" // Sample, AddResult, findByHash #include "bank_panel.h" // bankPanelRefresh #include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) #include "instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) #include "persist.h" // ReaSamplerSession @@ -81,19 +82,8 @@ std::string currentProjectDir() { return projectDirOfRpp(std::string(buf.data())); } -// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror -// of capture.cpp's readFileBytes — used to read the source and validate/hash the bank copy. -std::vector readFileBytes(const std::string& path) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return {}; - const std::streamsize n = f.tellg(); - if (n <= 0) return {}; - std::vector bytes(static_cast(n)); - f.seekg(0); - f.read(reinterpret_cast(bytes.data()), n); - if (!f) return {}; - return bytes; -} +// Whole-file reads (source read + bank-copy validate/hash) go through the shared +// core/util readFileBytes (Q-W1, T2-03): empty on any failure (missing / unreadable). // Writes a byte buffer to a file. Returns true on success. The caller is responsible for // ensuring the directory exists before calling. diff --git a/src/owned_manifest.cpp b/src/owned_manifest.cpp index 08c4798..ba21411 100644 --- a/src/owned_manifest.cpp +++ b/src/owned_manifest.cpp @@ -1,19 +1,17 @@ #include "owned_manifest.h" #include -#include + +#include "core/json/json.h" // owned_manifest implementation. // -// JSON is hand-rolled and self-contained (project convention: the pure core is -// dependency-free — no third-party JSON lib, mirror of bank_model / bank_book / -// tail_control). The shape is a single object with one string array: +// JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model / +// bank_book / tail_control). The shape is a single object with one string array: // // {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]} // -// so a compact writer + a focused string-array parser is all it needs — far smaller -// than bank_model's full recursive-descent parser, because there is exactly one key -// and one value kind. +// so a compact writer + a focused string-array domain parse is all it needs. namespace reasampler { @@ -56,237 +54,37 @@ bool OwnedFileManifest::contains(const std::string& relativePath) const { } // --------------------------------------------------------------------------- -// JSON writer +// JSON writer (shared core/json escape — byte-identical to the prior local one) // --------------------------------------------------------------------------- -namespace { - -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", - static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -} // namespace - std::string OwnedFileManifest::serialize() const { std::string out = "{\"owned\":["; for (std::size_t i = 0; i < paths_.size(); ++i) { if (i) out += ','; - writeEscaped(out, paths_[i]); + json::writeEscaped(out, paths_[i]); } out += "]}"; return out; } // --------------------------------------------------------------------------- -// JSON parser (string-array only) +// JSON parser (string-array-only DOMAIN grammar over the shared core/json +// lexical layer). Tolerates unknown keys (forward-compat) and requires the +// "owned" value to be an array of strings. // --------------------------------------------------------------------------- namespace { -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} - - // Parse the manifest object into `out`. Tolerates unknown keys (forward-compat) - // and requires the "owned" value to be an array of strings. - bool parseManifest(OwnedFileManifest& out); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - - bool parseString(std::string& out); - bool parseStringArray(std::vector& out); - bool skipValue(); // for forward-compat unknown keys -}; - -// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX for -// control chars). Positioned before the opening quote (skips leading whitespace). -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - - unsigned int hi = 0; - if (!readHex4(hi)) return false; - - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; // unpaired low surrogate - } - - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated string -} - -bool Parser::parseStringArray(std::vector& out) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; // empty array - for (;;) { - std::string s; - if (!parseString(s)) return false; - out.push_back(std::move(s)); - skipWs(); - if (consume(',')) continue; - if (consume(']')) return true; - return false; // neither separator nor terminator — malformed - } -} - -// Skip a single JSON value (string / array / object / bare scalar) so an unknown key -// does not abort the parse. Minimal: enough for forward-compat siblings we don't know. -bool Parser::skipValue() { - skipWs(); - if (eof()) return false; - char c = s_[pos_]; - if (c == '"') { - std::string tmp; - return parseString(tmp); - } - if (c == '[' || c == '{') { - // Balance nested brackets of either kind, ignoring bracket chars inside - // strings. Enough to step over an unknown nested value; not a full validator. - int depth = 0; - bool inStr = false; - while (!eof()) { - char d = s_[pos_]; - if (inStr) { - if (d == '\\') { pos_ += 2; continue; } - if (d == '"') inStr = false; - ++pos_; - continue; - } - if (d == '"') { inStr = true; ++pos_; continue; } - if (d == '[' || d == '{') ++depth; - else if (d == ']' || d == '}') { - --depth; - if (depth == 0) { ++pos_; return true; } - } - ++pos_; - } - return false; - } - // bare scalar (number / true / false / null) — read to the next structural char - while (!eof()) { - char d = s_[pos_]; - if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' || - d == '\n' || d == '\r') - break; - ++pos_; - } - return true; -} - -bool Parser::parseManifest(OwnedFileManifest& out) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // empty object -> empty manifest +bool parseManifest(json::Reader& r, OwnedFileManifest& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // empty object -> empty manifest for (;;) { std::string key; - if (!parseString(key)) return false; - if (!consume(':')) return false; + if (!r.parseKey(key)) return false; if (key == "owned") { std::vector paths; - if (!parseStringArray(paths)) return false; + if (!r.parseStringArray(paths)) return false; for (auto& p : paths) { // Feed through add() so the persisted invariants (dedup, reject // empty/absolute) are re-asserted on load — a hand-edited or corrupt @@ -294,21 +92,21 @@ bool Parser::parseManifest(OwnedFileManifest& out) { out.add(p); } } else { - if (!skipValue()) return false; // forward-compat: tolerate unknown keys + if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys } - skipWs(); - if (consume(',')) continue; - if (consume('}')) return true; + r.skipWs(); + if (r.consume(',')) continue; + if (r.consume('}')) return true; return false; } } } // namespace -std::optional OwnedFileManifest::deserialize(const std::string& json) { +std::optional OwnedFileManifest::deserialize(const std::string& blob) { OwnedFileManifest m; - Parser p(json); - if (!p.parseManifest(m)) return std::nullopt; + json::Reader r(blob); + if (!parseManifest(r, m)) return std::nullopt; return m; } diff --git a/src/provenance.cpp b/src/provenance.cpp index 89cd816..712394f 100644 --- a/src/provenance.cpp +++ b/src/provenance.cpp @@ -1,8 +1,8 @@ #include "provenance.h" #include -#include -#include + +#include "core/wire/wire.h" // provenance implementation — pure, self-contained (no third-party lib, mirror of // bank_model's hand-rolled encoding discipline). @@ -39,12 +39,12 @@ namespace { constexpr const char* kMagic = "rsprov1"; -// Append one length-prefixed field: ':' -void putField(std::string& out, const std::string& field) { - out += std::to_string(field.size()); - out += ':'; - out += field; -} +// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full +// hardening (incl. the fixed fieldInt range check that closes the old strtol +// silent-narrowing TODO). Only the %.17g double rendering stays local — it is +// this writer's convention, shared with the bank model's JSON doubles. +using wire::putField; +using Cursor = wire::Cursor; std::string dblToStr(double v) { char buf[32]; @@ -52,111 +52,6 @@ std::string dblToStr(double v) { return buf; } -// Cursor over the encoded string. All reads are bounds-checked; any short read -// fails the whole parse (ok_ latches false). -class Cursor { -public: - explicit Cursor(const std::string& s) : s_(s) {} - - bool ok() const { return ok_; } - bool atEnd() const { return pos_ >= s_.size(); } - - // Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or - // non-numeric length, a length that overflows SIZE_MAX, or a length that runs past - // the end. Hardened form backported from the assignment_request / sample_usage - // siblings (Q-W0 T2-01a): the digit count is capped at 20 (the decimal width of - // SIZE_MAX on a 64-bit host) so a crafted 200-digit length cannot accumulate past - // SIZE_MAX via repeated multiply, and the bounds check is subtraction-first so a - // huge `len` cannot wrap `start + len` past the end test. - bool field(std::string& out) { - if (!ok_) return false; - const std::size_t colon = s_.find(':', pos_); - if (colon == std::string::npos) return fail(); - if (colon == pos_) return fail(); // empty length token - // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. - if (colon - pos_ > 20u) return fail(); - std::size_t len = 0; - for (std::size_t i = pos_; i < colon; ++i) { - const char c = s_[i]; - if (c < '0' || c > '9') return fail(); - const std::size_t digit = static_cast(c - '0'); - // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. - if (len > (std::numeric_limits::max() - digit) / 10u) - return fail(); - len = len * 10u + digit; - } - const std::size_t start = colon + 1; - // Subtraction-first form: start + len cannot wrap on a huge len. - if (start > s_.size() || len > s_.size() - start) return fail(); - out.assign(s_, start, len); - pos_ = start + len; - return true; - } - - bool fieldInt(int& out) { - std::string f; - if (!field(f)) return false; - return toInt(f, out); - } - - // A length-prefixed unsigned decimal (the GUID count). Hardened (Q-W0 T2-01a, the - // sample_usage fieldCount pattern): fails on empty, non-digit, a digit run past 20 - // (SIZE_MAX's decimal width), or an accumulate that would overflow SIZE_MAX. - bool fieldSizeT(std::size_t& out) { - std::string f; - if (!field(f)) return false; - if (f.empty() || f.size() > 20u) return fail(); - std::size_t v = 0; - for (const char c : f) { - if (c < '0' || c > '9') return fail(); - const std::size_t digit = static_cast(c - '0'); - if (v > (std::numeric_limits::max() - digit) / 10u) - return fail(); - v = v * 10u + digit; - } - out = v; - return true; - } - - bool fieldDouble(double& out) { - std::string f; - if (!field(f)) return false; - const char* b = f.c_str(); - char* end = nullptr; - double v = std::strtod(b, &end); - if (end != b + f.size()) return fail(); - out = v; - return true; - } - - // Consumes an exact literal at the cursor (the magic tag). Fails if absent. - bool literal(const char* lit) { - if (!ok_) return false; - const std::string l(lit); - if (s_.compare(pos_, l.size(), l) != 0) return fail(); - pos_ += l.size(); - return true; - } - -private: - bool fail() { ok_ = false; return false; } - // TODO(Q-W1): strtol does not check errno/range here, so an out-of-range field narrows - // silently to LONG_MAX (then truncates into `int`) instead of failing parse. Flagged for - // the Q-W1 wire-codec collapse rather than fixed in place. - static bool toInt(const std::string& f, int& out) { - const char* b = f.c_str(); - char* end = nullptr; - long v = std::strtol(b, &end, 10); - if (end != b + f.size() || f.empty()) return false; - out = static_cast(v); - return true; - } - - const std::string& s_; - std::size_t pos_ = 0; - bool ok_ = true; -}; - } // namespace std::string fxChainIdentity(const std::vector& entries) { diff --git a/src/sample_usage.cpp b/src/sample_usage.cpp index 8414564..88d64f0 100644 --- a/src/sample_usage.cpp +++ b/src/sample_usage.cpp @@ -3,8 +3,8 @@ #include "sample_usage.h" #include -#include -#include + +#include "core/wire/wire.h" namespace reasampler { @@ -12,81 +12,13 @@ namespace { constexpr const char* kMagic = "rsusage1"; -// Append one length-prefixed field: ':' . The same wire idiom as -// assignment_request / provenance — one grammar across every ext-state seam. -void putField(std::string& out, const std::string& field) { - out += std::to_string(field.size()); - out += ':'; - out += field; -} - -// Bounds-checked cursor over the encoded string (the assignment_request Cursor, trimmed -// to the two field kinds this record needs). A short read latches ok_ false. -class Cursor { -public: - explicit Cursor(const std::string& s) : s_(s) {} - - bool ok() const { return ok_; } - bool atEnd() const { return pos_ >= s_.size(); } - - bool field(std::string& out) { - if (!ok_) return false; - const std::size_t colon = s_.find(':', pos_); - if (colon == std::string::npos) return fail(); - if (colon == pos_) return fail(); // empty length token - if (colon - pos_ > 20u) return fail(); // SIZE_MAX is 20 decimal digits - std::size_t len = 0; - for (std::size_t i = pos_; i < colon; ++i) { - const char c = s_[i]; - if (c < '0' || c > '9') return fail(); - const std::size_t digit = static_cast(c - '0'); - if (len > (std::numeric_limits::max() - digit) / 10u) - return fail(); - len = len * 10u + digit; - } - const std::size_t start = colon + 1; - if (start > s_.size() || len > s_.size() - start) return fail(); - out.assign(s_, start, len); - pos_ = start + len; - return true; - } - - // Consumes an exact literal at the cursor (the magic tag). Fails if absent. - bool literal(const char* lit) { - if (!ok_) return false; - std::size_t i = 0; - for (; lit[i] != '\0'; ++i) { - if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail(); - } - pos_ += i; - return true; - } - - // A length-prefixed unsigned decimal (the hold count). Fails on empty, non-digit, - // or a value past a sane ceiling (a record cannot hold more entries than bytes). - bool fieldCount(std::size_t& out) { - std::string f; - if (!field(f)) return false; - if (f.empty() || f.size() > 10u) return fail(); - std::size_t v = 0; - for (const char c : f) { - if (c < '0' || c > '9') return fail(); - v = v * 10u + static_cast(c - '0'); - } - out = v; - return true; - } - -private: - bool fail() { - ok_ = false; - return false; - } - - const std::string& s_; - std::size_t pos_ = 0; - bool ok_ = true; -}; +// The shared core/wire codec (Q-W1, T2-01b) — one grammar across every +// ext-state seam. The former local fieldCount (10-digit cap) is subsumed by the +// codec's fieldSizeT (20-digit cap + overflow-guarded accumulate): every count +// the old cap accepted decodes identically, and any larger count is rejected by +// the count-vs-wire-size sanity bound at the call site below. +using wire::putField; +using Cursor = wire::Cursor; } // namespace @@ -115,7 +47,7 @@ std::optional decodeUsageRecord(const std::string& wire) { else if (unionedField == "0") rec.unioned = false; else return std::nullopt; // anything else is corruption -> reject whole std::size_t count = 0; - if (!c.fieldCount(count)) return std::nullopt; + if (!c.fieldSizeT(count)) return std::nullopt; // Each hold needs at least 4 wire bytes ("0:0:"), so a count past wire.size()/4 is // provably bogus — reject before looping rather than iterating a crafted huge count. if (count > wire.size() / 4u + 1u) return std::nullopt; diff --git a/src/tail_control.cpp b/src/tail_control.cpp index 1d4063a..2683e2e 100644 --- a/src/tail_control.cpp +++ b/src/tail_control.cpp @@ -3,10 +3,9 @@ #include "tail_control.h" #include -#include #include -#include -#include + +#include "core/json/json.h" namespace reasampler { @@ -51,13 +50,13 @@ std::string tailToggleLabel(const TailSetting& setting) { // JSON round-trip // --------------------------------------------------------------------------- // -// The setting is a flat object of one enum + one double, so a compact hand-rolled -// writer + a tolerant minimal reader is the simplest thing that works (mirroring -// bank_model's dependency-free JSON choice). manualMs is emitted with 17 significant -// digits (%.17g) — the shortest form that round-trips every IEEE-754 double exactly — -// so deserialize(serialize(x)) == x holds bit-for-bit. deserialize is deliberately -// forgiving: any parse failure returns nullopt so the caller falls back to a default, -// exactly as an absent ext-state key does. +// The setting is a flat object of one enum + one double, riding the shared +// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader — +// the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17 +// significant digits (%.17g) — the shortest form that round-trips every IEEE-754 +// double exactly — so deserialize(serialize(x)) == x holds bit-for-bit. +// deserialize stays forgiving in outcome: any parse failure returns nullopt so +// the caller falls back to a default, exactly as an absent ext-state key does. namespace { @@ -81,48 +80,48 @@ std::optional modeFromInt(int v) { } } -// Find the value token following `"key":` in `json`. Returns a pointer just past the -// colon (skipping whitespace) or nullptr if the key is absent. Minimal: the writer -// emits exactly one flat object with unique keys, so a substring search is sufficient -// and there is no nesting to confuse it. -const char* valueAfterKey(const std::string& json, const char* key) { - const std::string needle = std::string("\"") + key + "\""; - const std::size_t pos = json.find(needle); - if (pos == std::string::npos) return nullptr; - const char* p = json.c_str() + pos + needle.size(); - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p; - if (*p != ':') return nullptr; - ++p; - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p; - return p; -} - } // namespace std::string serializeTailSetting(const TailSetting& setting) { - char buf[128]; - std::snprintf(buf, sizeof(buf), "{\"mode\":%d,\"manualMs\":%.17g}", - modeToInt(setting.mode), setting.manualMs); - return std::string(buf); + // Byte-identical to the former snprintf writer: {"mode":%d,"manualMs":%.17g}. + std::string out; + { + json::Writer w(out); + w.keyRaw("mode", json::numToStr(modeToInt(setting.mode))); + w.keyRaw("manualMs", json::numToStr(setting.manualMs)); + } // Writer closes the object here (see bank_model's NRVO note) + return out; } -std::optional deserializeTailSetting(const std::string& json) { - const char* modeTok = valueAfterKey(json, "mode"); - const char* msTok = valueAfterKey(json, "manualMs"); - if (!modeTok || !msTok) return std::nullopt; // absent key -> malformed -> default +std::optional deserializeTailSetting(const std::string& blob) { + json::Reader r(blob); + if (!r.consume('{')) return std::nullopt; - char* end = nullptr; - errno = 0; - const long modeVal = std::strtol(modeTok, &end, 10); - if (end == modeTok || errno != 0) return std::nullopt; - const std::optional mode = modeFromInt(static_cast(modeVal)); + int modeInt = 0; + double ms = 0.0; + bool haveMode = false, haveMs = false; + r.skipWs(); + if (!r.consume('}')) { + do { + std::string key; + if (!r.parseKey(key)) return std::nullopt; + if (key == "mode") { + if (!r.parseInt(modeInt)) return std::nullopt; + haveMode = true; + } else if (key == "manualMs") { + if (!r.parseDouble(ms)) return std::nullopt; + haveMs = true; + } else { + if (!r.skipValue()) return std::nullopt; // forward-compat + } + } while (r.consume(',')); + if (!r.consume('}')) return std::nullopt; + } + if (!haveMode || !haveMs) return std::nullopt; // absent key -> malformed -> default + + const std::optional mode = modeFromInt(modeInt); if (!mode) return std::nullopt; - end = nullptr; - errno = 0; - const double ms = std::strtod(msTok, &end); - if (end == msTok || errno != 0) return std::nullopt; - TailSetting out; out.mode = *mode; out.manualMs = ms; diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 9b6af0b..c215e56 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -2,19 +2,16 @@ #include #include -#include -#include -#include -#include #include #include +#include "core/json/json.h" #include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention // view_mode_model implementation. // -// JSON is hand-rolled and self-contained, mirroring bank_model's approach (brief: -// keep the pure core dependency-free — no third-party JSON lib). A compact writer +// JSON rides on the shared core/json lexical layer (Q-W1), mirroring bank_model. +// A compact writer // plus a recursive-descent parser covers the field set: the mode registry, the // GUID-keyed membership map, per-track snapshots (with a variable-length per-FX // offline vector), and the active mode. Ints are emitted plainly; strings are @@ -493,73 +490,12 @@ bool ViewModeModel::operator==(const ViewModeModel& o) const { namespace { -void writeEscaped(std::string& out, const std::string& s) { - out += '"'; - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - out += buf; - } else { - out += c; - } - } - } - out += '"'; -} - -std::string intToStr(int v) { - char buf[16]; - std::snprintf(buf, sizeof(buf), "%d", v); - return buf; -} - -void writeIntArray(std::string& out, const std::vector& v) { - out += '['; - for (std::size_t i = 0; i < v.size(); ++i) { - if (i) out += ','; - out += intToStr(v[i]); - } - out += ']'; -} - -class ObjWriter { -public: - explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } - ~ObjWriter() { out_ += '}'; } - - void keyRaw(const char* key, const std::string& rawValue) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - out_ += rawValue; - } - void keyStr(const char* key, const std::string& value) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - writeEscaped(out_, value); - } - void keyBegin(const char* key) { - sep(); - writeEscaped(out_, key); - out_ += ':'; - } - -private: - void sep() { if (first_) first_ = false; else out_ += ','; } - std::string& out_; - bool first_ = true; -}; +// Shared core/json emit helpers (Q-W1): same escape set + %d rendering as the +// prior file-local writer, so the emitted blob is byte-identical. +using json::writeEscaped; +using json::writeIntArray; +std::string intToStr(int v) { return json::numToStr(v); } +using ObjWriter = json::Writer; } // namespace @@ -660,250 +596,64 @@ std::string ViewModeModel::serialize() const { namespace { -class Parser { -public: - explicit Parser(const std::string& s) : s_(s) {} - bool parseModel(ViewModeModel& out); - -private: - const std::string& s_; - std::size_t pos_ = 0; - - bool eof() const { return pos_ >= s_.size(); } - - void skipWs() { - while (!eof()) { - char c = s_[pos_]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; - else break; - } - } - bool consume(char c) { - skipWs(); - if (eof() || s_[pos_] != c) return false; - ++pos_; - return true; - } - bool parseString(std::string& out); - bool parseRawScalar(std::string& out); - bool parseInt(int& out); - bool parseBool(bool& out); - bool parseKey(std::string& key); - bool skipValue(); - - bool parseModes(ModeRegistry& reg); - bool parseMembership(MembershipIndex& idx); - bool parseSnapshots(std::map& snaps); - bool parseLanes(LaneOwnershipIndex& idx); - bool parseIntArray(std::vector& out); -}; - -bool Parser::parseString(std::string& out) { - skipWs(); - if (eof() || s_[pos_] != '"') return false; - ++pos_; - out.clear(); - while (!eof()) { - char c = s_[pos_++]; - if (c == '"') return true; - if (c == '\\') { - if (eof()) return false; - char e = s_[pos_++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - auto readHex4 = [&](unsigned int& cp) -> bool { - if (pos_ + 4 > s_.size()) return false; - cp = 0; - for (int i = 0; i < 4; ++i) { - char h = s_[pos_++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else return false; - } - return true; - }; - unsigned int hi = 0; - if (!readHex4(hi)) return false; - unsigned int codePoint = hi; - if (hi >= 0xD800 && hi <= 0xDBFF) { - if (pos_ + 6 > s_.size()) return false; - if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; - pos_ += 2; - unsigned int lo = 0; - if (!readHex4(lo)) return false; - if (lo < 0xDC00 || lo > 0xDFFF) return false; - codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); - } else if (hi >= 0xDC00 && hi <= 0xDFFF) { - return false; - } - if (codePoint <= 0x7F) { - out += static_cast(codePoint); - } else if (codePoint <= 0x7FF) { - out += static_cast(0xC0 | (codePoint >> 6)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else if (codePoint <= 0xFFFF) { - out += static_cast(0xE0 | (codePoint >> 12)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } else { - out += static_cast(0xF0 | (codePoint >> 18)); - out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); - out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); - out += static_cast(0x80 | (codePoint & 0x3F)); - } - break; - } - default: return false; - } - } else { - out += c; - } - } - return false; // unterminated -} - -bool Parser::parseRawScalar(std::string& out) { - skipWs(); - std::size_t start = pos_; - while (!eof()) { - char c = s_[pos_]; - if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || - c == '\n' || c == '\r') - break; - ++pos_; - } - if (pos_ == start) return false; - out.assign(s_, start, pos_ - start); - return true; -} - -bool Parser::parseInt(int& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - const char* b = tok.c_str(); - char* end = nullptr; - errno = 0; - long long v = std::strtoll(b, &end, 10); - if (end != b + tok.size()) return false; - if (errno == ERANGE) return false; - if (v < INT_MIN || v > INT_MAX) return false; - out = static_cast(v); - return true; -} - -bool Parser::parseBool(bool& out) { - std::string tok; - if (!parseRawScalar(tok)) return false; - if (tok == "true") { out = true; return true; } - if (tok == "false") { out = false; return true; } - return false; -} - -bool Parser::parseKey(std::string& key) { - if (!parseString(key)) return false; - return consume(':'); -} - -bool Parser::skipValue() { - skipWs(); - if (eof()) return false; - char c = s_[pos_]; - if (c == '"') { std::string tmp; return parseString(tmp); } - if (c == '{' || c == '[') { - char open = c, close = (c == '{') ? '}' : ']'; - ++pos_; - int depth = 1; - while (!eof() && depth > 0) { - char d = s_[pos_]; - if (d == '"') { std::string tmp; if (!parseString(tmp)) return false; continue; } - if (d == open) ++depth; - else if (d == close) --depth; - ++pos_; - } - return depth == 0; - } - std::string tmp; - return parseRawScalar(tmp); -} - -bool Parser::parseIntArray(std::vector& out) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; - do { - int v = 0; - if (!parseInt(v)) return false; - out.push_back(v); - } while (consume(',')); - return consume(']'); -} +// The model DOMAIN grammar over the shared core/json lexical layer (Q-W1). // The registry starts seeded (Arrange + Design). Deserialization must reproduce the // serialized set exactly, so we replace the seeded contents with the parsed ones — // add() dedups by id, so a serialized Arrange/Design would otherwise be rejected as // duplicates and the ordinals/names would not round-trip. We therefore parse into a // fresh vector and swap. `reg` is passed empty (see parseModel). -bool Parser::parseModes(ModeRegistry& reg) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; // empty array (unusual, but valid) +bool parseModes(json::Reader& r, ModeRegistry& reg) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; // empty array (unusual, but valid) do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; Mode m; bool haveId = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "id") { if (!parseString(m.id)) return false; haveId = true; } - else if (k == "displayName") { if (!parseString(m.displayName)) return false; } - else if (k == "ordinal") { if (!parseInt(m.ordinal)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "id") { if (!r.parseString(m.id)) return false; haveId = true; } + else if (k == "displayName") { if (!r.parseString(m.displayName)) return false; } + else if (k == "ordinal") { if (!r.parseInt(m.ordinal)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveId || !reg.add(m)) return false; // malformed / duplicate id - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseMembership(MembershipIndex& idx) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; +bool parseMembership(json::Reader& r, MembershipIndex& idx) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string guid; Membership mem; bool haveGuid = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; } + if (!r.parseKey(k)) return false; + if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; } else if (k == "modes") { - if (!consume('[')) return false; - skipWs(); - if (!consume(']')) { + if (!r.consume('[')) return false; + r.skipWs(); + if (!r.consume(']')) { do { std::string id; - if (!parseString(id)) return false; + if (!r.parseString(id)) return false; mem.modeIds.insert(id); - } while (consume(',')); - if (!consume(']')) return false; + } while (r.consume(',')); + if (!r.consume(']')) return false; } } - else if (k == "showBoth") { if (!parseBool(mem.showBoth)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + else if (k == "showBoth") { if (!r.parseBool(mem.showBoth)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveGuid || guid.empty()) return false; // Install the entry verbatim (tag() would clear a multi-mode set and drop // show-both). A serialized entry is trusted to already satisfy the model's @@ -918,55 +668,55 @@ bool Parser::parseMembership(MembershipIndex& idx) { // mode that no longer exists has an immediate behavioral consequence, so it // is caught and the parse is rejected. if (!idx.restore(guid, mem)) return false; - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseSnapshots(std::map& snaps) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; +bool parseSnapshots(json::Reader& r, std::map& snaps) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string guid; TrackSnapshot snap; bool haveGuid = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; } - else if (k == "showInTcp") { if (!parseInt(snap.showInTcp)) return false; } - else if (k == "showInMixer") { if (!parseInt(snap.showInMixer)) return false; } - else if (k == "mainSend") { if (!parseInt(snap.mainSend)) return false; } - else if (k == "fxEnable") { if (!parseInt(snap.fxEnable)) return false; } - else if (k == "fxOffline") { if (!parseIntArray(snap.fxOffline)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; } + else if (k == "showInTcp") { if (!r.parseInt(snap.showInTcp)) return false; } + else if (k == "showInMixer") { if (!r.parseInt(snap.showInMixer)) return false; } + else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; } + else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; } + else if (k == "fxOffline") { if (!r.parseIntArray(snap.fxOffline)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; if (!haveGuid || guid.empty()) return false; snaps[guid] = snap; - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseLanes(LaneOwnershipIndex& idx) { - if (!consume('[')) return false; - skipWs(); - if (consume(']')) return true; +bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) { + if (!r.consume('[')) return false; + r.skipWs(); + if (r.consume(']')) return true; do { - if (!consume('{')) return false; + if (!r.consume('{')) return false; std::string trackGuid, laneKey, mode; bool haveTrack = false, haveLane = false, managed = false, haveManaged = false; do { std::string k; - if (!parseKey(k)) return false; - if (k == "trackGuid") { if (!parseString(trackGuid)) return false; haveTrack = true; } - else if (k == "laneKey") { if (!parseString(laneKey)) return false; haveLane = true; } - else if (k == "managed") { if (!parseBool(managed)) return false; haveManaged = true; } - else if (k == "mode") { if (!parseString(mode)) return false; } - else if (!skipValue()) return false; - } while (consume(',')); - if (!consume('}')) return false; + if (!r.parseKey(k)) return false; + if (k == "trackGuid") { if (!r.parseString(trackGuid)) return false; haveTrack = true; } + else if (k == "laneKey") { if (!r.parseString(laneKey)) return false; haveLane = true; } + else if (k == "managed") { if (!r.parseBool(managed)) return false; haveManaged = true; } + else if (k == "mode") { if (!r.parseString(mode)) return false; } + else if (!r.skipValue()) return false; + } while (r.consume(',')); + if (!r.consume('}')) return false; // Both keys mandatory and non-empty (they form the lane's identity). A managed // lane must carry a non-empty mode; a manual lane must not claim one. Enforcing // this on parse keeps a round-tripped index byte-for-byte identical to the @@ -980,14 +730,14 @@ bool Parser::parseLanes(LaneOwnershipIndex& idx) { if (!mode.empty()) return false; // manual lane must not carry a mode if (!idx.setManual(trackGuid, laneKey)) return false; } - } while (consume(',')); - return consume(']'); + } while (r.consume(',')); + return r.consume(']'); } -bool Parser::parseModel(ViewModeModel& out) { - if (!consume('{')) return false; - skipWs(); - if (consume('}')) return true; // lenient empty root ⇒ default-seeded model +bool parseModel(json::Reader& r, ViewModeModel& out) { + if (!r.consume('{')) return false; + r.skipWs(); + if (r.consume('}')) return true; // lenient empty root ⇒ default-seeded model ModeRegistry reg; // seeded default; REPLACED if a modes array is present bool haveModes = false; @@ -999,33 +749,33 @@ bool Parser::parseModel(ViewModeModel& out) { do { std::string key; - if (!parseKey(key)) return false; + if (!r.parseKey(key)) return false; if (key == "activeMode") { - if (!parseString(activeMode)) return false; + if (!r.parseString(activeMode)) return false; haveActive = true; } else if (key == "modes") { ModeRegistry fresh = ModeRegistry::makeEmpty(); // parse into empty, then own - if (!parseModes(fresh)) return false; + if (!parseModes(r, fresh)) return false; reg = fresh; haveModes = true; } else if (key == "membership") { - if (!parseMembership(membership)) return false; + if (!parseMembership(r, membership)) return false; } else if (key == "snapshots") { - if (!parseSnapshots(snaps)) return false; + if (!parseSnapshots(r, snaps)) return false; } else if (key == "lanes") { - if (!parseLanes(lanes)) return false; + if (!parseLanes(r, lanes)) return false; } else { // Unknown keys and the "version" field are skipped here. // "version" is serialized as a forward-compat placeholder — there is no // active version gate yet; all persisted data is parsed the same way // regardless of the value. A future gate would add a version branch here. - if (!skipValue()) return false; + if (!r.skipValue()) return false; } - } while (consume(',')); + } while (r.consume(',')); - if (!consume('}')) return false; - skipWs(); - if (!eof()) return false; // trailing garbage + if (!r.consume('}')) return false; + r.skipWs(); + if (!r.eof()) return false; // trailing garbage if (haveModes) out.modes() = reg; out.membership() = membership; @@ -1039,10 +789,10 @@ bool Parser::parseModel(ViewModeModel& out) { } // namespace -std::optional ViewModeModel::deserialize(const std::string& json) { +std::optional ViewModeModel::deserialize(const std::string& blob) { ViewModeModel vm; - Parser p(json); - if (!p.parseModel(vm)) return std::nullopt; + json::Reader r(blob); + if (!parseModel(r, vm)) return std::nullopt; return vm; } diff --git a/src/vst/bank_sync.cpp b/src/vst/bank_sync.cpp index 8ca894b..aff6c6b 100644 --- a/src/vst/bank_sync.cpp +++ b/src/vst/bank_sync.cpp @@ -3,27 +3,20 @@ #include "bank_sync.h" #include -#include #include +#include "core/wire/wire.h" + namespace reasampler::vst { std::int64_t parseBankGeneration(const std::string& raw) { - if (raw.empty()) return kBankGenerationAbsent; - - // Whole-string, non-negative decimal parse WITHOUT exceptions or locale surprises. - // A leading '+' / '-' , any non-digit, an empty digit run, or overflow past int64 max - // all reject to the absent default (0). Manual accumulation with an overflow guard so a + // Whole-string, non-negative decimal parse WITHOUT exceptions or locale + // surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading + // '+' / '-', any non-digit, an empty string, or overflow past int64 max all + // reject to the absent default (0); the guarded accumulate means a // pathologically long digit run can never wrap into a bogus small value. std::int64_t value = 0; - constexpr std::int64_t kMax = std::numeric_limits::max(); - for (const char c : raw) { - if (c < '0' || c > '9') return kBankGenerationAbsent; // any non-digit -> reject whole - const int digit = c - '0'; - // Guard value*10 + digit against overflow before performing it. - if (value > (kMax - digit) / 10) return kBankGenerationAbsent; // would overflow -> reject - value = value * 10 + digit; - } + if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent; return value; } diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 13f8a6c..1ec3362 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -15,6 +15,7 @@ #include "capture_browser.h" #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) #include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3) #include "editor_geometry.h" // Rect, contains @@ -770,16 +771,8 @@ const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& if (!relativePath.empty()) { const std::string projectDir = processor_->bridge().activeProjectDir(); const std::string abs = resolveBankFile(projectDir, relativePath); - std::vector bytes; - std::ifstream f(abs, std::ios::binary | std::ios::ate); - if (f) { - const std::streamoff size = f.tellg(); - if (size > 0) { - f.seekg(0, std::ios::beg); - bytes.resize(static_cast(size)); - if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); - } - } + // Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure. + const std::vector bytes = readFileBytes(abs); const WavLayout layout = parseWavLayout(bytes); if (layout.valid) { std::vector interleaved = diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index eb8f2d4..3cb03e2 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -20,6 +20,7 @@ #include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) #include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract) #include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) #include "reasampler_editor.h" @@ -69,19 +70,9 @@ std::string mintUsageInstanceGuid() { return std::string(buf); } -// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on -// any failure — the caller treats an unreadable WAV as "nothing to play". -std::vector readFileBytes(const std::string& path) { - std::vector bytes; - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f) return bytes; - const std::streamoff size = f.tellg(); - if (size <= 0) return bytes; - f.seekg(0, std::ios::beg); - bytes.resize(static_cast(size)); - if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); - return bytes; -} +// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03). +// Off-thread only (blocking file I/O). Empty on any failure — the caller treats +// an unreadable WAV as "nothing to play". // Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file // I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode diff --git a/tests/test_file_bytes.cpp b/tests/test_file_bytes.cpp new file mode 100644 index 0000000..d0bc358 --- /dev/null +++ b/tests/test_file_bytes.cpp @@ -0,0 +1,51 @@ +// Standalone tests for reasampler::readFileBytes — no REAPER, no framework. +// The ONE whole-file loader (Q-W1, T2-03) shared by both artifacts. Exercises +// the three-way contract: exact bytes back, empty on a missing file, empty on +// an empty file. Uses a scratch file in the test's working directory. + +#include "../src/core/util/file_bytes.h" + +#include +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static const char* kScratch = "file_bytes_scratch.bin"; + +static void testReadsExactBytesBack() { + // Binary content incl. NUL and 0xFF — the loader must be byte-transparent. + const std::vector payload = {0x00, 0x01, 0xFF, 0x7E, 0x00, 0x0A}; + { + std::ofstream f(kScratch, std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(payload.data()), + static_cast(payload.size())); + } + CHECK(readFileBytes(kScratch) == payload); + std::remove(kScratch); +} + +static void testMissingFileIsEmpty() { + CHECK(readFileBytes("no_such_file_anywhere.bin").empty()); +} + +static void testEmptyFileIsEmpty() { + { std::ofstream f(kScratch, std::ios::binary | std::ios::trunc); } + CHECK(readFileBytes(kScratch).empty()); + std::remove(kScratch); +} + +int main() { + testReadsExactBytesBack(); + testMissingFileIsEmpty(); + testEmptyFileIsEmpty(); + + if (g_fail == 0) std::printf("file_bytes: all tests passed\n"); + else std::printf("file_bytes: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_json.cpp b/tests/test_json.cpp new file mode 100644 index 0000000..687042c --- /dev/null +++ b/tests/test_json.cpp @@ -0,0 +1,296 @@ +// Standalone tests for reasampler::json — no REAPER, no framework. The ONE +// lexical JSON layer (Q-W1) behind bank_model / bank_book / view_mode_model / +// owned_manifest / tail_control. The consumers' own suites prove the domain +// grammars; this suite pins the LEXICAL contract — the escape set, the number +// renderings (byte-exact), the parse tolerances, and the reject paths — so a +// change here is caught before it silently shifts five persisted-blob formats. +// +// NOTE: json::Reader BORROWS its input string, so every test binds a named +// std::string first — never a temporary. + +#include "../src/core/json/json.h" + +#include +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// Convenience: parse helpers over a named buffer per call site. +static bool intFrom(const std::string& s, int& v) { json::Reader r(s); return r.parseInt(v); } +static bool int64From(const std::string& s, std::int64_t& v) { json::Reader r(s); return r.parseInt64(v); } +static bool doubleFrom(const std::string& s, double& v) { json::Reader r(s); return r.parseDouble(v); } +static bool boolFrom(const std::string& s, bool& v) { json::Reader r(s); return r.parseBool(v); } +static bool stringFrom(const std::string& s, std::string& v) { json::Reader r(s); return r.parseString(v); } + +// --- emit: writeEscaped ------------------------------------------------------- + +static void testEscapeExactBytes() { + // The seven short escapes + \u00XX for remaining control chars, verbatim + // pass-through otherwise. Byte-exact: this is the persisted-blob format. + std::string out; + json::writeEscaped(out, "a\"b\\c\n\t\x01z"); + CHECK(out == "\"a\\\"b\\\\c\\n\\t\\u0001z\""); +} + +static void testEscapeUtf8PassesThrough() { + // Multi-byte UTF-8 passes through verbatim; only C0 controls are \u-escaped. + std::string out; + json::writeEscaped(out, "gr\xC3\xBC n"); // "grü n" + CHECK(out == "\"gr\xC3\xBC n\""); +} + +// --- emit: numToStr ----------------------------------------------------------- + +static void testNumToStrIntForms() { + CHECK(json::numToStr(0) == "0"); + CHECK(json::numToStr(-7) == "-7"); + CHECK(json::numToStr(INT_MAX) == "2147483647"); + CHECK(json::numToStr(static_cast(1) << 40) == "1099511627776"); + CHECK(json::numToStr(2000.0) == "2000"); // %.17g drops the trailing .0 + CHECK(json::numToStr(0.5) == "0.5"); +} + +static void testDoubleRoundTripsBitForBit() { + // %.17g is the shortest form that round-trips every IEEE-754 double. + const double v = 3141.592653589793; + double back = 0.0; + CHECK(doubleFrom(json::numToStr(v), back)); + CHECK(back == v); +} + +// --- emit: Writer object grammar ---------------------------------------------- + +static void testWriterEmitsExactObjectBytes() { + std::string out; + { + json::Writer w(out); + w.keyRaw("a", json::numToStr(1)); + w.keyStr("b", "x\"y"); + w.keyBegin("c"); + { + json::Writer nested(out); + nested.keyRaw("d", json::numToStr(2.5)); + } + w.keyBegin("e"); + json::writeStringArray(out, {"p", "q"}); + w.keyBegin("f"); + json::writeIntArray(out, {1, 2}); + } + CHECK(out == "{\"a\":1,\"b\":\"x\\\"y\",\"c\":{\"d\":2.5}," + "\"e\":[\"p\",\"q\"],\"f\":[1,2]}"); +} + +static void testEmptyArraysEmitBrackets() { + std::string s, i; + json::writeStringArray(s, {}); + json::writeIntArray(i, {}); + CHECK(s == "[]"); + CHECK(i == "[]"); +} + +// --- Reader: strings ---------------------------------------------------------- + +static void testParseStringEscapes() { + std::string out; + CHECK(stringFrom(" \"a\\\"b\\\\c\\n\\u0041\"", out)); + CHECK(out == "a\"b\\c\nA"); +} + +static void testParseStringSurrogatePairToUtf8() { + // \uD83D\uDE00 (grinning face) -> F0 9F 98 80. + std::string out; + CHECK(stringFrom("\"\\ud83d\\ude00\"", out)); + CHECK(out == "\xF0\x9F\x98\x80"); +} + +static void testParseStringRejectsMalformed() { + std::string out; + CHECK(!stringFrom("\"unterminated", out)); + CHECK(!stringFrom("\"bad\\qescape\"", out)); + CHECK(!stringFrom("\"\\ud800 alone\"", out)); // unpaired high surrogate + CHECK(!stringFrom("\"\\udc00\"", out)); // unpaired low surrogate + CHECK(!stringFrom("noquote", out)); +} + +// --- Reader: numbers ---------------------------------------------------------- + +static void testParseIntAcceptsAndRejects() { + int v = 0; + CHECK(intFrom("42,", v)); CHECK(v == 42); + CHECK(intFrom("-7}", v)); CHECK(v == -7); + CHECK(intFrom("2147483647]", v)); CHECK(v == INT_MAX); + // Out of int range is REJECTED (the unified guard every consumer now shares). + CHECK(!intFrom("2147483648,", v)); + CHECK(!intFrom("1.5,", v)); + CHECK(!intFrom("x,", v)); + CHECK(!intFrom("", v)); +} + +static void testParseInt64RangeAndReject() { + std::int64_t v = 0; + CHECK(int64From("9223372036854775807,", v)); + CHECK(v == 9223372036854775807LL); + CHECK(!int64From("9223372036854775808,", v)); // ERANGE -> reject +} + +static void testParseDoubleRejectsRangeAndGarbage() { + double v = 0; + CHECK(!doubleFrom("1e999,", v)); // ERANGE + CHECK(!doubleFrom("1.5abc,", v)); // trailing bytes + CHECK(doubleFrom("2.5}", v)); CHECK(v == 2.5); +} + +static void testParseBool() { + bool v = false; + CHECK(boolFrom("true,", v)); CHECK(v); + CHECK(boolFrom("false]", v)); CHECK(!v); + CHECK(!boolFrom("TRUE,", v)); +} + +// --- Reader: structure -------------------------------------------------------- + +static void testExpectNullOr() { + { + const std::string s = "null,"; + json::Reader r(s); + bool wasNull = false; + CHECK(r.expectNullOr(wasNull)); CHECK(wasNull); CHECK(r.consume(',')); + } + { + const std::string s = "\"x\""; + json::Reader r(s); + bool wasNull = true; + std::string v; + CHECK(r.expectNullOr(wasNull)); CHECK(!wasNull); + CHECK(r.parseString(v)); CHECK(v == "x"); + } + { + const std::string s; + json::Reader r(s); + bool wasNull = false; + CHECK(!r.expectNullOr(wasNull)); + } +} + +static void testParseKeyConsumesColon() { + const std::string s = " \"k\" : 1"; + json::Reader r(s); + std::string k; + int v = 0; + CHECK(r.parseKey(k)); + CHECK(k == "k"); + CHECK(r.parseInt(v)); + CHECK(v == 1); +} + +static void testParseArraysAppend() { + { + const std::string s = "[\"a\",\"b\"]"; + json::Reader r(s); + std::vector v; + CHECK(r.parseStringArray(v)); + CHECK(v.size() == 2 && v[0] == "a" && v[1] == "b"); + } + { + const std::string s = "[]"; + json::Reader r(s); + std::vector v; + CHECK(r.parseStringArray(v)); CHECK(v.empty()); + } + { + const std::string s = "[1,2]"; // non-string element + json::Reader r(s); + std::vector v; + CHECK(!r.parseStringArray(v)); + } + { + const std::string s = "[1,2,3]"; + json::Reader r(s); + std::vector v; + CHECK(r.parseIntArray(v)); + CHECK(v.size() == 3 && v[2] == 3); + } + { + const std::string s = "[1,"; // truncated + json::Reader r(s); + std::vector v; + CHECK(!r.parseIntArray(v)); + } +} + +static void testSkipValueOverNestedShapes() { + // Skips a nested object whose strings contain structural chars, then the + // cursor sits exactly on the next separator. + const std::string s = "{\"deep\":[\"}\",{\"x\":\"]\"}]},7"; + json::Reader r(s); + CHECK(r.skipValue()); + CHECK(r.consume(',')); + int v = 0; + CHECK(r.parseInt(v)); + CHECK(v == 7); +} + +static void testCaptureValueVerbatim() { + const std::string s = " {\"a\":[1,\"{\"]} ,tail"; + json::Reader r(s); + std::string raw; + CHECK(r.captureValue(raw)); + CHECK(raw == "{\"a\":[1,\"{\"]}"); + CHECK(r.consume(',')); +} + +static void testWriterOutputParsesBack() { + // The emitted object is consumable by the Reader — the seam the five + // consumers rely on (writer and reader agree on one dialect). + std::string out; + { + json::Writer w(out); + w.keyStr("name", "tab\there"); + w.keyRaw("n", json::numToStr(-3)); + } + json::Reader r(out); + CHECK(r.consume('{')); + std::string k1, v1; + CHECK(r.parseKey(k1) && k1 == "name"); + CHECK(r.parseString(v1) && v1 == "tab\there"); + CHECK(r.consume(',')); + std::string k2; + int v2 = 0; + CHECK(r.parseKey(k2) && k2 == "n"); + CHECK(r.parseInt(v2) && v2 == -3); + CHECK(r.consume('}')); + r.skipWs(); + CHECK(r.eof()); +} + +int main() { + testEscapeExactBytes(); + testEscapeUtf8PassesThrough(); + testNumToStrIntForms(); + testDoubleRoundTripsBitForBit(); + testWriterEmitsExactObjectBytes(); + testEmptyArraysEmitBrackets(); + testParseStringEscapes(); + testParseStringSurrogatePairToUtf8(); + testParseStringRejectsMalformed(); + testParseIntAcceptsAndRejects(); + testParseInt64RangeAndReject(); + testParseDoubleRejectsRangeAndGarbage(); + testParseBool(); + testExpectNullOr(); + testParseKeyConsumesColon(); + testParseArraysAppend(); + testSkipValueOverNestedShapes(); + testCaptureValueVerbatim(); + testWriterOutputParsesBack(); + + if (g_fail == 0) std::printf("json: all tests passed\n"); + else std::printf("json: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_tail_control.cpp b/tests/test_tail_control.cpp index 09d20b0..20738ad 100644 --- a/tests/test_tail_control.cpp +++ b/tests/test_tail_control.cpp @@ -135,6 +135,20 @@ static void testRoundTripAuto() { CHECK(back && settingsEqual(*back, s)); } +static void testSerializeByteIdentity() { + // Q-W1 structural-dedupe guard: the core/json-backed writer must emit the + // EXACT bytes the former snprintf writer produced ({"mode":%d,"manualMs":%.17g}) + // and re-serializing a round-tripped setting must be byte-identical — the blob + // lives in the .rpp, so a byte shift would dirty every saved project. + TailSetting s; // None + 2000.0 default + CHECK(serializeTailSetting(s) == "{\"mode\":0,\"manualMs\":2000}"); + TailSetting man; man.mode = TailMode::Manual; man.manualMs = 3141.592653589793; + const std::string json = serializeTailSetting(man); + auto back = deserializeTailSetting(json); + CHECK(back.has_value()); + CHECK(back && serializeTailSetting(*back) == json); // stable second round-trip +} + static void testDeserializeEmptyIsDefault() { // An absent/empty stored value (older project) -> nullopt, so the caller falls // back to the default. This is the graceful-old-project path the brief requires. @@ -164,6 +178,7 @@ int main() { testRoundTripNoneDefault(); testRoundTripManualArbitraryMs(); testRoundTripAuto(); + testSerializeByteIdentity(); testDeserializeEmptyIsDefault(); testDeserializeMalformedIsDefault(); diff --git a/tests/test_wire.cpp b/tests/test_wire.cpp new file mode 100644 index 0000000..3f58399 --- /dev/null +++ b/tests/test_wire.cpp @@ -0,0 +1,190 @@ +// Standalone tests for reasampler::wire — no REAPER, no framework. The ONE +// length-prefixed ext-state wire codec (Q-W1, T2-01b) behind provenance / +// assignment_request / sample_usage / bank_sync. The consumers' own suites +// prove their record grammars round-trip; this suite pins the CODEC contract — +// byte-exact encode, the full hardening (length caps, overflow guards, +// subtraction-first bounds), and the fixed fieldInt range rejection. +// +// NOTE: wire::Cursor BORROWS its input string, so every helper takes a named / +// reference-bound std::string — the Cursor never outlives its buffer. + +#include "../src/core/wire/wire.h" + +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// One length-prefixed field around `v` — the writer-side convention. +static std::string enc(const std::string& v) { + std::string out; + wire::putField(out, v); + return out; +} + +// Single-field decode helpers (Cursor + buffer share the call's lifetime). +static bool fieldFrom(const std::string& s, std::string& out) { + wire::Cursor c(s); + return c.field(out); +} +static bool i64From(const std::string& s, std::int64_t& out) { + wire::Cursor c(s); + return c.fieldInt64(out); +} +static bool intFrom(const std::string& s, int& out) { + wire::Cursor c(s); + return c.fieldInt(out); +} +static bool sizeFrom(const std::string& s, std::size_t& out) { + wire::Cursor c(s); + return c.fieldSizeT(out); +} +static bool dblFrom(const std::string& s, double& out) { + wire::Cursor c(s); + return c.fieldDouble(out); +} + +// --- putField: byte-exact encode ---------------------------------------------- + +static void testPutFieldExactBytes() { + std::string out; + wire::putField(out, "abc"); + CHECK(out == "3:abc"); + wire::putField(out, ""); // empty field is legal: "0:" + CHECK(out == "3:abc0:"); + wire::putField(out, "a:b"); // ':' inside a value cannot shift the parse + CHECK(out == "3:abc0:3:a:b"); +} + +// --- Cursor: round-trip + literal --------------------------------------------- + +static void testFieldRoundTripIncludingSeparators() { + std::string out = "magic"; + wire::putField(out, "12:34"); // digits + colons in the value + wire::putField(out, ""); + wire::putField(out, "tail"); + wire::Cursor c(out); + std::string a, b, t; + CHECK(c.literal("magic")); + CHECK(c.field(a) && a == "12:34"); + CHECK(c.field(b) && b.empty()); + CHECK(c.field(t) && t == "tail"); + CHECK(c.ok() && c.atEnd()); +} + +static void testLiteralMismatchFails() { + const std::string good = "rsprov1x"; + const std::string wrong = "rsprov0x"; + const std::string truncated = "rspro"; + { wire::Cursor c(good); CHECK(c.literal("rsprov1")); } + { wire::Cursor c(wrong); CHECK(!c.literal("rsprov1")); CHECK(!c.ok()); } + { wire::Cursor c(truncated); CHECK(!c.literal("rsprov1")); } +} + +// --- Cursor: field hardening --------------------------------------------------- + +static void testFieldRejectsMalformedLengths() { + std::string f; + CHECK(!fieldFrom("abc", f)); // no colon + CHECK(!fieldFrom(":x", f)); // empty length + CHECK(!fieldFrom("2x:ab", f)); // non-digit length + CHECK(!fieldFrom("9:ab", f)); // runs past end + // A 200-digit length cannot accumulate past SIZE_MAX (digit-run cap). + CHECK(!fieldFrom(std::string(200, '9') + ":x", f)); + // Exactly-20-digit values: SIZE_MAX itself passes the accumulate but fails the + // bounds check; one past SIZE_MAX trips the overflow guard. + CHECK(!fieldFrom("18446744073709551615:x", f)); + CHECK(!fieldFrom("18446744073709551616:x", f)); +} + +static void testFailureLatchesOk() { + // After one failed read every subsequent read fails too — the caller may + // check ok() once at the end (the "never a partial value" discipline). + const std::string s = "3:abc"; + wire::Cursor c(s); + std::string f; + CHECK(!c.literal("nope")); + CHECK(!c.field(f)); + CHECK(!c.ok()); +} + +// --- Cursor: fieldInt64 / fieldInt -------------------------------------------- + +static void testFieldInt64AcceptsAndRejects() { + std::int64_t v = 0; + CHECK(i64From(enc("12345"), v)); CHECK(v == 12345); + CHECK(i64From(enc("-42"), v)); CHECK(v == -42); + CHECK(i64From(enc("9223372036854775807"), v)); // INT64_MAX + CHECK(v == 9223372036854775807LL); + CHECK(!i64From(enc("9223372036854775808"), v)); // overflow + CHECK(!i64From(enc("12345678901234567890"), v)); // 20-digit cap + CHECK(!i64From(enc("-"), v)); // bare sign + CHECK(!i64From(enc("1a"), v)); // non-digit + CHECK(!i64From(enc(""), v)); // empty +} + +static void testFieldIntRejectsOutOfIntRange() { + // The fixed form of the old provenance strtol TODO: an out-of-int-range field + // FAILS the parse instead of silently narrowing. + int v = 0; + CHECK(intFrom(enc("2147483647"), v)); CHECK(v == 2147483647); + CHECK(intFrom(enc("-2147483648"), v)); CHECK(v == -2147483647 - 1); + CHECK(!intFrom(enc("2147483648"), v)); + CHECK(!intFrom(enc("3000000000"), v)); +} + +// --- Cursor: fieldSizeT / fieldDouble ------------------------------------------ + +static void testFieldSizeT() { + std::size_t v = 1; + CHECK(sizeFrom(enc("0"), v)); CHECK(v == 0); + CHECK(sizeFrom(enc("4096"), v)); CHECK(v == 4096); + CHECK(!sizeFrom(enc("-1"), v)); // sign = non-digit + CHECK(!sizeFrom(enc(std::string(21, '9')), v)); // 21-digit cap + CHECK(!sizeFrom(enc("18446744073709551616"), v)); // overflow guard +} + +static void testFieldDoubleRoundTrip() { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.17g", 3141.592653589793); + double v = 0; + CHECK(dblFrom(enc(buf), v)); + CHECK(v == 3141.592653589793); + CHECK(!dblFrom(enc("1.5x"), v)); // trailing bytes +} + +// --- parseUnsignedDecimal (the bank_sync generation core) ----------------------- + +static void testParseUnsignedDecimal() { + std::int64_t v = 0; + CHECK(wire::parseUnsignedDecimal("0", v) && v == 0); + CHECK(wire::parseUnsignedDecimal("1721947293", v) && v == 1721947293); + CHECK(wire::parseUnsignedDecimal("9223372036854775807", v) && v == 9223372036854775807LL); + CHECK(!wire::parseUnsignedDecimal("", v)); + CHECK(!wire::parseUnsignedDecimal("+5", v)); // sign rejected (non-digit) + CHECK(!wire::parseUnsignedDecimal("-5", v)); + CHECK(!wire::parseUnsignedDecimal("12a", v)); + CHECK(!wire::parseUnsignedDecimal("9223372036854775808", v)); // overflow + CHECK(!wire::parseUnsignedDecimal(std::string(40, '9'), v)); // long run cannot wrap +} + +int main() { + testPutFieldExactBytes(); + testFieldRoundTripIncludingSeparators(); + testLiteralMismatchFails(); + testFieldRejectsMalformedLengths(); + testFailureLatchesOk(); + testFieldInt64AcceptsAndRejects(); + testFieldIntRejectsOutOfIntRange(); + testFieldSizeT(); + testFieldDoubleRoundTrip(); + testParseUnsignedDecimal(); + + if (g_fail == 0) std::printf("wire: all tests passed\n"); + else std::printf("wire: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}