Q-W1 pt1: extract core/json (json::Reader/Writer), collapse wire Cursor family into core/wire, shared readFileBytes — five JSON decoders and three cursor copies deleted, byte-identical formats, 59/59 green

This commit is contained in:
2026-07-28 19:59:02 -04:00
parent 88e7765ee5
commit 67a41728f3
25 changed files with 1695 additions and 1696 deletions
+46 -2
View File
@@ -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(WDL_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/WDL/WDL)
set(SWELL ${WDL_INC}/swell) 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. # 1) Pure model library — NO REAPER, NO SWELL. Builds & tests anywhere.
# The sampler's heart: Sample metadata + BankIndex (Milestone 1). # The sampler's heart: Sample metadata + BankIndex (Milestone 1).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
add_library(bank_model STATIC src/bank_model.cpp) add_library(bank_model STATIC src/bank_model.cpp)
target_include_directories(bank_model PUBLIC src) 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 # 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) add_library(view_mode_model STATIC src/view_mode_model.cpp)
target_include_directories(view_mode_model PUBLIC src) 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 # 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 # durable-key convention in lane_keys (laneNameForMode), so the model depends on that
# pure sibling. PUBLIC so every consumer (tests + module) resolves the symbol. # 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) add_library(tail_control STATIC src/tail_control.cpp)
target_include_directories(tail_control PUBLIC src) target_include_directories(tail_control PUBLIC src)
target_link_libraries(tail_control PUBLIC render_settings) 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 # 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) add_library(bank_book STATIC src/bank_book.cpp)
target_include_directories(bank_book PUBLIC src) target_include_directories(bank_book PUBLIC src)
target_link_libraries(bank_book PUBLIC bank_model) 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 # 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) add_library(owned_manifest STATIC src/owned_manifest.cpp)
target_include_directories(owned_manifest PUBLIC src) 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 # 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) add_library(provenance STATIC src/provenance.cpp)
target_include_directories(provenance PUBLIC src) 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 # 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) add_library(assignment_request STATIC src/assignment_request.cpp)
target_include_directories(assignment_request PUBLIC src) 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: # 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) add_library(sample_usage STATIC src/sample_usage.cpp)
target_include_directories(sample_usage PUBLIC src) 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 # 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). # 3) Standalone tests for the pure modules (run without launching REAPER).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
enable_testing() 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) add_executable(bank_model_tests tests/test_bank_model.cpp)
target_link_libraries(bank_model_tests PRIVATE bank_model) target_link_libraries(bank_model_tests PRIVATE bank_model)
add_test(NAME bank_model_tests COMMAND bank_model_tests) 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) add_library(bank_sync STATIC src/vst/bank_sync.cpp)
target_include_directories(bank_sync PUBLIC src/vst src) target_include_directories(bank_sync PUBLIC src/vst src)
target_link_libraries(bank_sync PUBLIC assignment_request) 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 # 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 + # 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/card_drag.cpp
src/usage_scan.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}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # 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' # "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 sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
waveform_view bank_sync browser_scroll note_entry param_slider waveform_view bank_sync browser_scroll note_entry param_slider
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit 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; # 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. # 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}) target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
+6 -110
View File
@@ -2,8 +2,7 @@
#include "assignment_request.h" #include "assignment_request.h"
#include <cstddef> #include "core/wire/wire.h"
#include <limits>
namespace reasampler { namespace reasampler {
@@ -11,114 +10,11 @@ namespace {
constexpr const char* kMagic = "rsassign1"; constexpr const char* kMagic = "rsassign1";
// Append one length-prefixed field: <decimal-len> ':' <bytes>. Mirror of // The shared core/wire codec (Q-W1, T2-01b) — the same field grammar + hardening
// provenance's putField so the two seams share one wire idiom. // this file previously carried as its own Cursor copy. "never UB, never a
void putField(std::string& out, const std::string& field) { // partial value" is upheld in the codec.
out += std::to_string(field.size()); using wire::putField;
out += ':'; using Cursor = wire::Cursor;
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<std::size_t>(c - '0');
// Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail.
if (len > (std::numeric_limits<std::size_t>::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<std::int64_t>(c - '0');
// Overflow guard: v * 10 + digit must not exceed INT64_MAX.
if (v > (std::numeric_limits<std::int64_t>::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;
};
} // namespace } // namespace
+68 -306
View File
@@ -1,14 +1,14 @@
#include "bank_book.h" #include "bank_book.h"
#include <algorithm> #include <algorithm>
#include <climits>
#include <unordered_set> #include <unordered_set>
#include "core/json/json.h"
// bank_book implementation. // bank_book implementation.
// //
// JSON is hand-rolled and self-contained, matching the house style of bank_model // JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model
// and view_mode_model (brief: keep the pure core dependency-free — no third-party // and view_mode_model. The book blob nests one bank object per bank, each carrying that
// JSON lib). The book blob nests one bank object per bank, each carrying that
// bank's BankIndex serialized by bank_model's OWN writer (BankIndex::serialize), // 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 // 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 // here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
@@ -138,7 +138,7 @@ SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pai
} }
// SlotMap::serialize is defined in the JSON writer section below (it reuses the // 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 // BankBook — construction + bank lookup
@@ -550,64 +550,10 @@ std::vector<std::string> BankBook::referencedPaths() const {
namespace { namespace {
void writeEscaped(std::string& out, const std::string& s) { // Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the
out += '"'; // prior file-local writer carried, so the emitted blob is byte-identical.
for (char c : s) { std::string intToStr(int v) { return json::numToStr(v); }
switch (c) { using ObjWriter = json::Writer;
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<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(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;
};
} // namespace } // namespace
@@ -660,223 +606,38 @@ std::string BankBook::serialize() const {
namespace { namespace {
class Parser { // The book DOMAIN grammar over the shared core/json lexical layer (Q-W1).
public: // parseBank parses one bank object; parseSlots the "slots" array ([{id, slot},
explicit Parser(const std::string& s) : s_(s) {} // ...]) 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<std::pair<std::string, int>>& out);
// Parses a book blob into a bank set + active id. On success fills the out-params bool parseBank(json::Reader& r, Bank& b) {
// and returns true. Distinguishes the legacy shape (a bare bank_index object: has if (!r.consume('{')) return false;
// "samples", no "banks") from the book shape (has "banks"): a legacy blob yields a r.skipWs();
// single pool bank carrying the migrated index and an empty active id (⇒ pool). The if (r.consume('}')) return false; // a bank object must at least carry an id
// member deserialize() adopts the result (ordinal normalize + active resolve).
bool parseBook(std::vector<Bank>& 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<std::pair<std::string, int>>& 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<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(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<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(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<int>(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 haveId = false; bool haveId = false;
bool haveIndex = false; bool haveIndex = false;
do { do {
std::string key; std::string key;
if (!parseKey(key)) return false; if (!r.parseKey(key)) return false;
if (key == "id") { if (key == "id") {
if (!parseString(b.id)) return false; if (!r.parseString(b.id)) return false;
haveId = true; haveId = true;
} else if (key == "displayName") { } else if (key == "displayName") {
if (!parseString(b.displayName)) return false; if (!r.parseString(b.displayName)) return false;
} else if (key == "ordinal") { } else if (key == "ordinal") {
if (!parseInt(b.ordinal)) return false; if (!r.parseInt(b.ordinal)) return false;
} else if (key == "index") { } else if (key == "index") {
std::string raw; std::string raw;
if (!captureValue(raw)) return false; if (!r.captureValue(raw)) return false;
auto idx = BankIndex::deserialize(raw); auto idx = BankIndex::deserialize(raw);
if (!idx) return false; // a malformed nested index fails the whole parse if (!idx) return false; // a malformed nested index fails the whole parse
b.index = std::move(*idx); 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 // nothing because the key never appears); when present it drives the
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. // bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership.
std::vector<std::pair<std::string, int>> pairs; std::vector<std::pair<std::string, int>> pairs;
if (!parseSlots(pairs)) return false; if (!parseSlots(r, pairs)) return false;
b.slots = SlotMap::fromEntries(pairs); b.slots = SlotMap::fromEntries(pairs);
} else { } 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 (!haveId || b.id.empty()) return false; // id keys the registry
if (!haveIndex) return false; // every bank persists its index if (!haveIndex) return false; // every bank persists its index
return true; return true;
} }
bool Parser::parseSlots(std::vector<std::pair<std::string, int>>& out) { bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out) {
out.clear(); out.clear();
if (!consume('[')) return false; if (!r.consume('[')) return false;
skipWs(); r.skipWs();
if (consume(']')) return true; // empty slot array — a bank with no positions yet if (r.consume(']')) return true; // empty slot array — a bank with no positions yet
do { do {
if (!consume('{')) return false; if (!r.consume('{')) return false;
std::string id; std::string id;
int slot = 0; int slot = 0;
bool haveId = false, haveSlot = false; bool haveId = false, haveSlot = false;
do { do {
std::string k; std::string k;
if (!parseKey(k)) return false; if (!r.parseKey(k)) return false;
if (k == "id") { if (!parseString(id)) return false; haveId = true; } if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
else if (k == "slot") { if (!parseInt(slot)) return false; haveSlot = true; } else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
else { if (!skipValue()) return false; } // forward-compat else { if (!r.skipValue()) return false; } // forward-compat
} while (consume(',')); } while (r.consume(','));
if (!consume('}')) return false; if (!r.consume('}')) return false;
if (!haveId || !haveSlot) return false; // a slot entry needs both if (!haveId || !haveSlot) return false; // a slot entry needs both
out.emplace_back(std::move(id), slot); out.emplace_back(std::move(id), slot);
} while (consume(',')); } while (r.consume(','));
return consume(']'); return r.consume(']');
} }
bool Parser::parseBook(std::vector<Bank>& banks, std::string& activeBank) { bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks,
std::string& activeBank) {
banks.clear(); banks.clear();
activeBank.clear(); activeBank.clear();
if (!consume('{')) return false; if (!r.consume('{')) return false;
skipWs(); r.skipWs();
if (consume('}')) return false; // an empty object is neither shape → malformed 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 // 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). // "samples" key with no "banks" ⇒ legacy shape (promote into the pool).
@@ -938,41 +700,41 @@ bool Parser::parseBook(std::vector<Bank>& banks, std::string& activeBank) {
do { do {
std::string key; std::string key;
if (!parseKey(key)) return false; if (!r.parseKey(key)) return false;
if (key == "banks") { if (key == "banks") {
sawBanks = true; sawBanks = true;
if (!consume('[')) return false; if (!r.consume('[')) return false;
skipWs(); r.skipWs();
if (!consume(']')) { if (!r.consume(']')) {
do { do {
Bank b; Bank b;
if (!parseBank(b)) return false; if (!parseBank(r, b)) return false;
parsedBanks.push_back(std::move(b)); parsedBanks.push_back(std::move(b));
} while (consume(',')); } while (r.consume(','));
if (!consume(']')) return false; if (!r.consume(']')) return false;
} }
} else if (key == "activeBank") { } else if (key == "activeBank") {
if (!parseString(activeBank)) return false; if (!r.parseString(activeBank)) return false;
} else if (key == "samples") { } else if (key == "samples") {
// Legacy marker. The legacy index is re-parsed from the whole input below // 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 // (BankIndex::deserialize owns that shape); here we only skip the value to
// keep the scan well-formed and note that we saw it. // keep the scan well-formed and note that we saw it.
sawSamples = true; sawSamples = true;
if (!skipValue()) return false; if (!r.skipValue()) return false;
} else { } 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; if (!r.consume('}')) return false;
skipWs(); r.skipWs();
if (!eof()) return false; // trailing garbage if (!r.eof()) return false; // trailing garbage
// --- Legacy migration: a bare bank_index (samples, no banks) → pool. --- // --- Legacy migration: a bare bank_index (samples, no banks) → pool. ---
if (!sawBanks) { if (!sawBanks) {
if (!sawSamples) return false; // neither shape's marker → malformed if (!sawSamples) return false; // neither shape's marker → malformed
auto legacy = BankIndex::deserialize(s_); auto legacy = BankIndex::deserialize(raw);
if (!legacy) return false; if (!legacy) return false;
Bank pool; Bank pool;
pool.id = kPoolBankId; pool.id = kPoolBankId;
@@ -1059,11 +821,11 @@ void BankBook::adoptBanks(std::vector<Bank>&& banks, const std::string& activeBa
activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId); activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId);
} }
std::optional<BankBook> BankBook::deserialize(const std::string& json) { std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
std::vector<Bank> banks; std::vector<Bank> banks;
std::string activeBank; std::string activeBank;
Parser p(json); json::Reader r(blob);
if (!p.parseBook(banks, activeBank)) return std::nullopt; if (!parseBook(r, blob, banks, activeBank)) return std::nullopt;
BankBook book; BankBook book;
book.adoptBanks(std::move(banks), activeBank); book.adoptBanks(std::move(banks), activeBank);
+89 -395
View File
@@ -1,17 +1,15 @@
#include "bank_model.h" #include "bank_model.h"
#include <cctype> #include <cctype>
#include <cerrno>
#include <cstdio> #include "core/json/json.h"
#include <cstdlib>
#include <cstring>
// bank_model implementation. // bank_model implementation.
// //
// JSON is hand-rolled and self-contained (brief: keep the pure core // JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer,
// dependency-free — no third-party JSON lib, no WDL coupling). The field set is // no per-module Parser copy). The field set is a flat struct of primitives,
// a flat struct of primitives, strings, one enum, a small string array, and a // strings, one enum, a small string array, and a few optionals, so a compact
// few optionals, so a compact writer + recursive-descent parser is the simplest // writer + recursive-descent DOMAIN parser over json::Reader is the simplest
// thing that works. Doubles are emitted with 17 significant digits (%.17g), the // thing that works. Doubles are emitted with 17 significant digits (%.17g), the
// shortest form that round-trips every IEEE-754 double exactly, so the // shortest form that round-trips every IEEE-754 double exactly, so the
// deserialize(serialize(x)) == x invariant holds bit-for-bit. // deserialize(serialize(x)) == x invariant holds bit-for-bit.
@@ -147,84 +145,10 @@ std::vector<Sample> BankIndex::byTier(Tier tier) const {
namespace { namespace {
void writeEscaped(std::string& out, const std::string& s) { using json::numToStr;
out += '"'; using json::writeEscaped;
for (char c : s) { using json::writeStringArray;
switch (c) { using ObjWriter = json::Writer;
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<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(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<long long>(v));
return buf;
}
std::string numToStr(int v) { return numToStr(static_cast<std::int64_t>(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<std::string>& v) {
out += '[';
for (std::size_t i = 0; i < v.size(); ++i) {
if (i) out += ',';
writeEscaped(out, v[i]);
}
out += ']';
}
void writeSample(std::string& out, const Sample& s) { void writeSample(std::string& out, const Sample& s) {
ObjWriter w(out); ObjWriter w(out);
@@ -317,346 +241,116 @@ std::string BankIndex::serialize() const {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// JSON parser (recursive descent). Returns false on any malformed input; never // JSON parser (recursive descent over the shared json::Reader). Returns false
// reads out of bounds. Only supports the subset our writer emits. // 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 { namespace {
class Parser { bool parseSample(json::Reader& r, Sample& s) {
public: if (!r.consume('{')) return false;
explicit Parser(const std::string& s) : s_(s) {} r.skipWs();
if (r.consume('}')) return true; // empty object (shouldn't happen, but valid)
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<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(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<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(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<std::int64_t>(v);
return true;
}
bool Parser::parseInt(int& out) {
std::int64_t v = 0;
if (!parseInt64(v)) return false;
out = static_cast<int>(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)
do { do {
std::string key; std::string key;
if (!parseKey(key)) return false; if (!r.parseKey(key)) return false;
if (key == "id") { if (key == "id") {
if (!parseString(s.id)) return false; if (!r.parseString(s.id)) return false;
} else if (key == "displayName") { } else if (key == "displayName") {
if (!parseString(s.displayName)) return false; if (!r.parseString(s.displayName)) return false;
} else if (key == "relativePath") { } else if (key == "relativePath") {
if (!parseString(s.relativePath)) return false; if (!r.parseString(s.relativePath)) return false;
} else if (key == "sourceMode") { } else if (key == "sourceMode") {
int v = 0; int v = 0;
if (!parseInt(v)) return false; if (!r.parseInt(v)) return false;
// Valid range: MasterMix(0) .. Realtime(5). // Valid range: MasterMix(0) .. Realtime(5).
if (v < static_cast<int>(SourceMode::MasterMix) || if (v < static_cast<int>(SourceMode::MasterMix) ||
v > static_cast<int>(SourceMode::Realtime)) v > static_cast<int>(SourceMode::Realtime))
return false; return false;
s.sourceMode = static_cast<SourceMode>(v); s.sourceMode = static_cast<SourceMode>(v);
} else if (key == "sourceRange") { } else if (key == "sourceRange") {
if (!consume('{')) return false; if (!r.consume('{')) return false;
do { do {
std::string rk; std::string rk;
if (!parseKey(rk)) return false; if (!r.parseKey(rk)) return false;
double dv = 0.0; double dv = 0.0;
if (!parseDouble(dv)) return false; if (!r.parseDouble(dv)) return false;
if (rk == "startSeconds") s.sourceRange.startSeconds = dv; if (rk == "startSeconds") s.sourceRange.startSeconds = dv;
else if (rk == "endSeconds") s.sourceRange.endSeconds = dv; else if (rk == "endSeconds") s.sourceRange.endSeconds = dv;
else if (rk == "startPpq") s.sourceRange.startPpq = dv; else if (rk == "startPpq") s.sourceRange.startPpq = dv;
else if (rk == "endPpq") s.sourceRange.endPpq = dv; else if (rk == "endPpq") s.sourceRange.endPpq = dv;
} while (consume(',')); } while (r.consume(','));
if (!consume('}')) return false; if (!r.consume('}')) return false;
} else if (key == "trackGuids") { } else if (key == "trackGuids") {
if (!consume('[')) return false; if (!r.consume('[')) return false;
skipWs(); r.skipWs();
if (!consume(']')) { if (!r.consume(']')) {
do { do {
std::string g; std::string g;
if (!parseString(g)) return false; if (!r.parseString(g)) return false;
s.trackGuids.push_back(g); s.trackGuids.push_back(g);
} while (consume(',')); } while (r.consume(','));
if (!consume(']')) return false; if (!r.consume(']')) return false;
} }
} else if (key == "wetDry") { } else if (key == "wetDry") {
if (!parseDouble(s.wetDry)) return false; if (!r.parseDouble(s.wetDry)) return false;
} else if (key == "channelCount") { } else if (key == "channelCount") {
if (!parseInt(s.channelCount)) return false; if (!r.parseInt(s.channelCount)) return false;
} else if (key == "sampleRate") { } else if (key == "sampleRate") {
if (!parseInt(s.sampleRate)) return false; if (!r.parseInt(s.sampleRate)) return false;
} else if (key == "lengthSeconds") { } else if (key == "lengthSeconds") {
if (!parseDouble(s.lengthSeconds)) return false; if (!r.parseDouble(s.lengthSeconds)) return false;
} else if (key == "lengthBeats") { } else if (key == "lengthBeats") {
if (!parseDouble(s.lengthBeats)) return false; if (!r.parseDouble(s.lengthBeats)) return false;
} else if (key == "captureTempo") { } else if (key == "captureTempo") {
if (!parseDouble(s.captureTempo)) return false; if (!r.parseDouble(s.captureTempo)) return false;
} else if (key == "captureTimeSigNum") { } else if (key == "captureTimeSigNum") {
if (!parseInt(s.captureTimeSigNum)) return false; if (!r.parseInt(s.captureTimeSigNum)) return false;
} else if (key == "captureTimeSigDenom") { } else if (key == "captureTimeSigDenom") {
if (!parseInt(s.captureTimeSigDenom)) return false; if (!r.parseInt(s.captureTimeSigDenom)) return false;
} else if (key == "key") { } else if (key == "key") {
bool wasNull = false; bool wasNull = false;
if (!expectNullOr(wasNull)) return false; if (!r.expectNullOr(wasNull)) return false;
if (wasNull) { if (wasNull) {
s.key.reset(); s.key.reset();
} else { } else {
std::string k; std::string k;
if (!parseString(k)) return false; if (!r.parseString(k)) return false;
s.key = k; s.key = k;
} }
} else if (key == "rootNote") { } else if (key == "rootNote") {
bool wasNull = false; bool wasNull = false;
if (!expectNullOr(wasNull)) return false; if (!r.expectNullOr(wasNull)) return false;
if (wasNull) { if (wasNull) {
s.rootNote.reset(); s.rootNote.reset();
} else { } else {
int v = 0; int v = 0;
if (!parseInt(v)) return false; if (!r.parseInt(v)) return false;
// Valid MIDI note range: 0..127 inclusive (boundaries valid). // Valid MIDI note range: 0..127 inclusive (boundaries valid).
if (v < 0 || v > 127) return false; if (v < 0 || v > 127) return false;
s.rootNote = v; s.rootNote = v;
} }
} else if (key == "loop") { } else if (key == "loop") {
bool wasNull = false; bool wasNull = false;
if (!expectNullOr(wasNull)) return false; if (!r.expectNullOr(wasNull)) return false;
if (wasNull) { if (wasNull) {
s.loop.reset(); s.loop.reset();
} else { } else {
if (!consume('{')) return false; if (!r.consume('{')) return false;
LoopPoints lp; LoopPoints lp;
do { do {
std::string lk; std::string lk;
if (!parseKey(lk)) return false; if (!r.parseKey(lk)) return false;
std::int64_t lv = 0; std::int64_t lv = 0;
if (!parseInt64(lv)) return false; if (!r.parseInt64(lv)) return false;
if (lk == "start") lp.start = lv; if (lk == "start") lp.start = lv;
else if (lk == "end") lp.end = lv; else if (lk == "end") lp.end = lv;
} while (consume(',')); } while (r.consume(','));
if (!consume('}')) return false; if (!r.consume('}')) return false;
// Invariant: 0 <= start <= end. start == end is a valid zero-length // Invariant: 0 <= start <= end. start == end is a valid zero-length
// marker; a negative index or start > end is malformed, not silently // marker; a negative index or start > end is malformed, not silently
// clamped (mirrors the enum-range rejection above). // clamped (mirrors the enum-range rejection above).
@@ -664,89 +358,89 @@ bool Parser::parseSample(Sample& s) {
s.loop = lp; s.loop = lp;
} }
} else if (key == "levels") { } else if (key == "levels") {
if (!consume('{')) return false; if (!r.consume('{')) return false;
do { do {
std::string lk; std::string lk;
if (!parseKey(lk)) return false; if (!r.parseKey(lk)) return false;
double dv = 0.0; double dv = 0.0;
if (!parseDouble(dv)) return false; if (!r.parseDouble(dv)) return false;
if (lk == "peakDb") s.levels.peakDb = dv; if (lk == "peakDb") s.levels.peakDb = dv;
else if (lk == "rmsDb") s.levels.rmsDb = dv; else if (lk == "rmsDb") s.levels.rmsDb = dv;
else if (lk == "lufs") s.levels.lufs = dv; else if (lk == "lufs") s.levels.lufs = dv;
} while (consume(',')); } while (r.consume(','));
if (!consume('}')) return false; if (!r.consume('}')) return false;
} else if (key == "clipped") { } else if (key == "clipped") {
if (!parseBool(s.clipped)) return false; if (!r.parseBool(s.clipped)) return false;
} else if (key == "tier") { } else if (key == "tier") {
int v = 0; int v = 0;
if (!parseInt(v)) return false; if (!r.parseInt(v)) return false;
// Valid range: Scratch(0) .. Archive(1). // Valid range: Scratch(0) .. Archive(1).
if (v < static_cast<int>(Tier::Scratch) || if (v < static_cast<int>(Tier::Scratch) ||
v > static_cast<int>(Tier::Archive)) v > static_cast<int>(Tier::Archive))
return false; return false;
s.tier = static_cast<Tier>(v); s.tier = static_cast<Tier>(v);
} else if (key == "contentHash") { } else if (key == "contentHash") {
if (!parseString(s.contentHash)) return false; if (!r.parseString(s.contentHash)) return false;
} else if (key == "provenance") { } else if (key == "provenance") {
bool wasNull = false; bool wasNull = false;
if (!expectNullOr(wasNull)) return false; if (!r.expectNullOr(wasNull)) return false;
if (wasNull) { if (wasNull) {
s.provenance.reset(); s.provenance.reset();
} else { } else {
if (!consume('{')) return false; if (!r.consume('{')) return false;
Provenance p; Provenance p;
do { do {
std::string pk; std::string pk;
if (!parseKey(pk)) return false; if (!r.parseKey(pk)) return false;
std::string pv; std::string pv;
if (!parseString(pv)) return false; if (!r.parseString(pv)) return false;
if (pk == "parentSampleId") p.parentSampleId = pv; if (pk == "parentSampleId") p.parentSampleId = pv;
else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv; else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv;
} while (consume(',')); } while (r.consume(','));
if (!consume('}')) return false; if (!r.consume('}')) return false;
s.provenance = p; s.provenance = p;
} }
} else if (key == "createdTimestamp") { } else if (key == "createdTimestamp") {
if (!parseInt64(s.createdTimestamp)) return false; if (!r.parseInt64(s.createdTimestamp)) return false;
} else { } 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) { bool parseIndex(json::Reader& r, BankIndex& out) {
if (!consume('{')) return false; if (!r.consume('{')) return false;
skipWs(); r.skipWs();
if (consume('}')) return true; // empty object — vacuously an empty index if (r.consume('}')) return true; // empty object — vacuously an empty index
std::vector<Sample> parsed; std::vector<Sample> parsed;
do { do {
std::string key; std::string key;
if (!parseKey(key)) return false; if (!r.parseKey(key)) return false;
if (key == "samples") { if (key == "samples") {
if (!consume('[')) return false; if (!r.consume('[')) return false;
skipWs(); r.skipWs();
if (!consume(']')) { if (!r.consume(']')) {
do { do {
Sample s; Sample s;
if (!parseSample(s)) return false; if (!parseSample(r, s)) return false;
parsed.push_back(std::move(s)); parsed.push_back(std::move(s));
} while (consume(',')); } while (r.consume(','));
if (!consume(']')) return false; if (!r.consume(']')) return false;
} }
} else { } 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. // Trailing garbage after the root object is malformed.
skipWs(); r.skipWs();
if (!eof()) return false; if (!r.eof()) return false;
// Rebuild via add() so the same invariants (relative-path, dedup) that guard // Rebuild via add() so the same invariants (relative-path, dedup) that guard
// live inserts also guard deserialized data. Rejected/collapsed entries are // live inserts also guard deserialized data. Rejected/collapsed entries are
@@ -757,10 +451,10 @@ bool Parser::parseIndex(BankIndex& out) {
} // namespace } // namespace
std::optional<BankIndex> BankIndex::deserialize(const std::string& json) { std::optional<BankIndex> BankIndex::deserialize(const std::string& blob) {
BankIndex idx; BankIndex idx;
Parser p(json); json::Reader r(blob);
if (!p.parseIndex(idx)) return std::nullopt; if (!parseIndex(r, idx)) return std::nullopt;
return idx; return idx;
} }
+4 -14
View File
@@ -43,6 +43,7 @@
#include <vector> #include <vector>
#include "capture_paths.h" #include "capture_paths.h"
#include "core/util/file_bytes.h"
#include "render_settings.h" #include "render_settings.h"
#define REAPERAPI_MINIMAL #define REAPERAPI_MINIMAL
@@ -226,20 +227,9 @@ std::string makeUniqueTag() {
return std::to_string(static_cast<long long>(now)); return std::to_string(static_cast<long long>(now));
} }
// Reads the whole file into a byte buffer. Returns an empty vector on any I/O // Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
// failure (the caller then leaves contentHash empty — the safe, confirm-eliciting // empty on any I/O failure (the caller then leaves contentHash empty — the safe,
// direction for an unreadable file). // confirm-eliciting direction for an unreadable file).
std::vector<std::uint8_t> 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<std::uint8_t> bytes(static_cast<std::size_t>(size));
f.seekg(0);
f.read(reinterpret_cast<char*>(bytes.data()), size);
if (!f) return {};
return bytes;
}
} // namespace } // namespace
+6 -16
View File
@@ -79,6 +79,7 @@
#include <vector> #include <vector>
#include "capture_paths.h" // hashBytes, deriveBankPaths #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 "peaks.h" // lastFrameAboveThreshold, AudioSample
#include "realtime_record.h" #include "realtime_record.h"
#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd #include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
@@ -323,20 +324,9 @@ private:
namespace { namespace {
// Reads the whole file into a byte buffer. Empty vector on any I/O failure — the // Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
// caller treats an unreadable file as "skip the trim" (keep the untrimmed window), // empty on any I/O failure — the caller treats an unreadable file as "skip the
// never as a corruption of the recorded audio. // trim" (keep the untrimmed window), never as a corruption of the recorded audio.
std::vector<std::uint8_t> 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<std::uint8_t> bytes(static_cast<std::size_t>(size));
f.seekg(0);
f.read(reinterpret_cast<char*>(bytes.data()), size);
if (!f) return {};
return bytes;
}
// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields). // Patches a little-endian uint32 into a byte buffer at `off` (the header size fields).
void writeU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) { void writeU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
@@ -371,7 +361,7 @@ double trimAutoTailInPlace(const std::string& path,
double rangeEndSeconds) { double rangeEndSeconds) {
constexpr double kNoTrim = -1.0; constexpr double kNoTrim = -1.0;
std::vector<std::uint8_t> bytes = readAllBytes(path); std::vector<std::uint8_t> bytes = readFileBytes(path);
if (bytes.empty()) return kNoTrim; if (bytes.empty()) return kNoTrim;
const reasampler::WavLayout layout = parseWavLayout(bytes); const reasampler::WavLayout layout = parseWavLayout(bytes);
@@ -533,7 +523,7 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) {
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats // contentHash empty — the safe, confirm-eliciting direction (bank_model treats
// "" as non-participating). // "" as non-participating).
{ {
const std::vector<std::uint8_t> fileBytes = readAllBytes(destPath); const std::vector<std::uint8_t> fileBytes = readFileBytes(destPath);
if (!fileBytes.empty()) { if (!fileBytes.empty()) {
result.sample.contentHash = hashWavContent(fileBytes); result.sample.contentHash = hashWavContent(fileBytes);
} }
+319
View File
@@ -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 <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
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<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(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<long long>(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<std::string>& 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<int>& 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<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(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<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(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<std::int64_t>(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<int>(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<std::string>& 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<int>& 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
+149
View File
@@ -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 <cstdint>
#include <string>
#include <vector>
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<std::string>& v);
void writeIntArray(std::string& out, const std::vector<int>& 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> — 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<std::string>& out);
bool parseIntArray(std::vector<int>& 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
+21
View File
@@ -0,0 +1,21 @@
// core/util/file_bytes implementation — see file_bytes.h.
#include "core/util/file_bytes.h"
#include <fstream>
namespace reasampler {
std::vector<std::uint8_t> 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<std::uint8_t> bytes(static_cast<std::size_t>(size));
f.seekg(0);
f.read(reinterpret_cast<char*>(bytes.data()), size);
if (!f) return {};
return bytes;
}
} // namespace reasampler
+19
View File
@@ -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 <cstdint>
#include <string>
#include <vector>
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<std::uint8_t> readFileBytes(const std::string& path);
} // namespace reasampler
+134
View File
@@ -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 <cstdlib>
#include <limits>
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<std::int64_t>::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<std::size_t>(c - '0');
// Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail.
if (len > (std::numeric_limits<std::size_t>::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<std::int64_t>(c - '0');
// Overflow guard: v * 10 + digit must not exceed INT64_MAX.
if (v > (std::numeric_limits<std::int64_t>::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<int>::min() || v > std::numeric_limits<int>::max())
return fail();
out = static_cast<int>(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<std::size_t>(c - '0');
if (v > (std::numeric_limits<std::size_t>::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
+88
View File
@@ -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 `<decimal-len>':'<bytes>` 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 <cstddef>
#include <cstdint>
#include <string>
namespace reasampler::wire {
// Append one length-prefixed field: <decimal-len> ':' <bytes>
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
+3 -13
View File
@@ -22,6 +22,7 @@
#include "bank_model.h" // Sample, AddResult, findByHash #include "bank_model.h" // Sample, AddResult, findByHash
#include "bank_panel.h" // bankPanelRefresh #include "bank_panel.h" // bankPanelRefresh
#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent #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.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId)
#include "instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) #include "instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block)
#include "persist.h" // ReaSamplerSession #include "persist.h" // ReaSamplerSession
@@ -81,19 +82,8 @@ std::string currentProjectDir() {
return projectDirOfRpp(std::string(buf.data())); return projectDirOfRpp(std::string(buf.data()));
} }
// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror // Whole-file reads (source read + bank-copy validate/hash) go through the shared
// of capture.cpp's readFileBytes — used to read the source and validate/hash the bank copy. // core/util readFileBytes (Q-W1, T2-03): empty on any failure (missing / unreadable).
std::vector<std::uint8_t> 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<std::uint8_t> bytes(static_cast<std::size_t>(n));
f.seekg(0);
f.read(reinterpret_cast<char*>(bytes.data()), n);
if (!f) return {};
return bytes;
}
// Writes a byte buffer to a file. Returns true on success. The caller is responsible for // Writes a byte buffer to a file. Returns true on success. The caller is responsible for
// ensuring the directory exists before calling. // ensuring the directory exists before calling.
+23 -225
View File
@@ -1,19 +1,17 @@
#include "owned_manifest.h" #include "owned_manifest.h"
#include <cctype> #include <cctype>
#include <cstdio>
#include "core/json/json.h"
// owned_manifest implementation. // owned_manifest implementation.
// //
// JSON is hand-rolled and self-contained (project convention: the pure core is // JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model /
// dependency-free — no third-party JSON lib, mirror of bank_model / bank_book / // bank_book / tail_control). The shape is a single object with one string array:
// tail_control). The shape is a single object with one string array:
// //
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]} // {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
// //
// so a compact writer + a focused string-array parser is all it needs — far smaller // so a compact writer + a focused string-array domain parse is all it needs.
// than bank_model's full recursive-descent parser, because there is exactly one key
// and one value kind.
namespace reasampler { 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<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x",
static_cast<unsigned char>(c));
out += buf;
} else {
out += c;
}
}
}
out += '"';
}
} // namespace
std::string OwnedFileManifest::serialize() const { std::string OwnedFileManifest::serialize() const {
std::string out = "{\"owned\":["; std::string out = "{\"owned\":[";
for (std::size_t i = 0; i < paths_.size(); ++i) { for (std::size_t i = 0; i < paths_.size(); ++i) {
if (i) out += ','; if (i) out += ',';
writeEscaped(out, paths_[i]); json::writeEscaped(out, paths_[i]);
} }
out += "]}"; out += "]}";
return 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 { namespace {
class Parser { bool parseManifest(json::Reader& r, OwnedFileManifest& out) {
public: if (!r.consume('{')) return false;
explicit Parser(const std::string& s) : s_(s) {} r.skipWs();
if (r.consume('}')) return true; // empty object -> empty manifest
// 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<std::string>& 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<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(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<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
}
break;
}
default: return false;
}
} else {
out += c;
}
}
return false; // unterminated string
}
bool Parser::parseStringArray(std::vector<std::string>& 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
for (;;) { for (;;) {
std::string key; std::string key;
if (!parseString(key)) return false; if (!r.parseKey(key)) return false;
if (!consume(':')) return false;
if (key == "owned") { if (key == "owned") {
std::vector<std::string> paths; std::vector<std::string> paths;
if (!parseStringArray(paths)) return false; if (!r.parseStringArray(paths)) return false;
for (auto& p : paths) { for (auto& p : paths) {
// Feed through add() so the persisted invariants (dedup, reject // Feed through add() so the persisted invariants (dedup, reject
// empty/absolute) are re-asserted on load — a hand-edited or corrupt // empty/absolute) are re-asserted on load — a hand-edited or corrupt
@@ -294,21 +92,21 @@ bool Parser::parseManifest(OwnedFileManifest& out) {
out.add(p); out.add(p);
} }
} else { } else {
if (!skipValue()) return false; // forward-compat: tolerate unknown keys if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys
} }
skipWs(); r.skipWs();
if (consume(',')) continue; if (r.consume(',')) continue;
if (consume('}')) return true; if (r.consume('}')) return true;
return false; return false;
} }
} }
} // namespace } // namespace
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& json) { std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& blob) {
OwnedFileManifest m; OwnedFileManifest m;
Parser p(json); json::Reader r(blob);
if (!p.parseManifest(m)) return std::nullopt; if (!parseManifest(r, m)) return std::nullopt;
return m; return m;
} }
+8 -113
View File
@@ -1,8 +1,8 @@
#include "provenance.h" #include "provenance.h"
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <limits> #include "core/wire/wire.h"
// provenance implementation — pure, self-contained (no third-party lib, mirror of // provenance implementation — pure, self-contained (no third-party lib, mirror of
// bank_model's hand-rolled encoding discipline). // bank_model's hand-rolled encoding discipline).
@@ -39,12 +39,12 @@ namespace {
constexpr const char* kMagic = "rsprov1"; constexpr const char* kMagic = "rsprov1";
// Append one length-prefixed field: <decimal-len> ':' <bytes> // The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full
void putField(std::string& out, const std::string& field) { // hardening (incl. the fixed fieldInt range check that closes the old strtol
out += std::to_string(field.size()); // silent-narrowing TODO). Only the %.17g double rendering stays local — it is
out += ':'; // this writer's convention, shared with the bank model's JSON doubles.
out += field; using wire::putField;
} using Cursor = wire::Cursor;
std::string dblToStr(double v) { std::string dblToStr(double v) {
char buf[32]; char buf[32];
@@ -52,111 +52,6 @@ std::string dblToStr(double v) {
return buf; 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<std::size_t>(c - '0');
// Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail.
if (len > (std::numeric_limits<std::size_t>::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<std::size_t>(c - '0');
if (v > (std::numeric_limits<std::size_t>::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<int>(v);
return true;
}
const std::string& s_;
std::size_t pos_ = 0;
bool ok_ = true;
};
} // namespace } // namespace
std::string fxChainIdentity(const std::vector<FxIdentityEntry>& entries) { std::string fxChainIdentity(const std::vector<FxIdentityEntry>& entries) {
+10 -78
View File
@@ -3,8 +3,8 @@
#include "sample_usage.h" #include "sample_usage.h"
#include <cctype> #include <cctype>
#include <cstddef>
#include <limits> #include "core/wire/wire.h"
namespace reasampler { namespace reasampler {
@@ -12,81 +12,13 @@ namespace {
constexpr const char* kMagic = "rsusage1"; constexpr const char* kMagic = "rsusage1";
// Append one length-prefixed field: <decimal-len> ':' <bytes>. The same wire idiom as // The shared core/wire codec (Q-W1, T2-01b) — one grammar across every
// assignment_request / provenance — one grammar across every ext-state seam. // ext-state seam. The former local fieldCount (10-digit cap) is subsumed by the
void putField(std::string& out, const std::string& field) { // codec's fieldSizeT (20-digit cap + overflow-guarded accumulate): every count
out += std::to_string(field.size()); // the old cap accepted decodes identically, and any larger count is rejected by
out += ':'; // the count-vs-wire-size sanity bound at the call site below.
out += field; using wire::putField;
} using Cursor = wire::Cursor;
// 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<std::size_t>(c - '0');
if (len > (std::numeric_limits<std::size_t>::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<std::size_t>(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;
};
} // namespace } // namespace
@@ -115,7 +47,7 @@ std::optional<UsageRecord> decodeUsageRecord(const std::string& wire) {
else if (unionedField == "0") rec.unioned = false; else if (unionedField == "0") rec.unioned = false;
else return std::nullopt; // anything else is corruption -> reject whole else return std::nullopt; // anything else is corruption -> reject whole
std::size_t count = 0; 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 // 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. // provably bogus — reject before looping rather than iterating a crafted huge count.
if (count > wire.size() / 4u + 1u) return std::nullopt; if (count > wire.size() / 4u + 1u) return std::nullopt;
+43 -44
View File
@@ -3,10 +3,9 @@
#include "tail_control.h" #include "tail_control.h"
#include <algorithm> #include <algorithm>
#include <cerrno>
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <cstring> #include "core/json/json.h"
namespace reasampler { namespace reasampler {
@@ -51,13 +50,13 @@ std::string tailToggleLabel(const TailSetting& setting) {
// JSON round-trip // JSON round-trip
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// //
// The setting is a flat object of one enum + one double, so a compact hand-rolled // The setting is a flat object of one enum + one double, riding the shared
// writer + a tolerant minimal reader is the simplest thing that works (mirroring // core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader —
// bank_model's dependency-free JSON choice). manualMs is emitted with 17 significant // the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17
// digits (%.17g) — the shortest form that round-trips every IEEE-754 double exactly — // significant digits (%.17g) — the shortest form that round-trips every IEEE-754
// so deserialize(serialize(x)) == x holds bit-for-bit. deserialize is deliberately // double exactly — so deserialize(serialize(x)) == x holds bit-for-bit.
// forgiving: any parse failure returns nullopt so the caller falls back to a default, // deserialize stays forgiving in outcome: any parse failure returns nullopt so
// exactly as an absent ext-state key does. // the caller falls back to a default, exactly as an absent ext-state key does.
namespace { namespace {
@@ -81,48 +80,48 @@ std::optional<TailMode> 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 } // namespace
std::string serializeTailSetting(const TailSetting& setting) { std::string serializeTailSetting(const TailSetting& setting) {
char buf[128]; // Byte-identical to the former snprintf writer: {"mode":%d,"manualMs":%.17g}.
std::snprintf(buf, sizeof(buf), "{\"mode\":%d,\"manualMs\":%.17g}", std::string out;
modeToInt(setting.mode), setting.manualMs); {
return std::string(buf); 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<TailSetting> deserializeTailSetting(const std::string& json) { std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
const char* modeTok = valueAfterKey(json, "mode"); json::Reader r(blob);
const char* msTok = valueAfterKey(json, "manualMs"); if (!r.consume('{')) return std::nullopt;
if (!modeTok || !msTok) return std::nullopt; // absent key -> malformed -> default
char* end = nullptr; int modeInt = 0;
errno = 0; double ms = 0.0;
const long modeVal = std::strtol(modeTok, &end, 10); bool haveMode = false, haveMs = false;
if (end == modeTok || errno != 0) return std::nullopt; r.skipWs();
const std::optional<TailMode> mode = modeFromInt(static_cast<int>(modeVal)); 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<TailMode> mode = modeFromInt(modeInt);
if (!mode) return std::nullopt; 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; TailSetting out;
out.mode = *mode; out.mode = *mode;
out.manualMs = ms; out.manualMs = ms;
+93 -343
View File
@@ -2,19 +2,16 @@
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
#include <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <set> #include <set>
#include <utility> #include <utility>
#include "core/json/json.h"
#include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention #include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention
// view_mode_model implementation. // view_mode_model implementation.
// //
// JSON is hand-rolled and self-contained, mirroring bank_model's approach (brief: // JSON rides on the shared core/json lexical layer (Q-W1), mirroring bank_model.
// keep the pure core dependency-free — no third-party JSON lib). A compact writer // A compact writer
// plus a recursive-descent parser covers the field set: the mode registry, the // 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 // 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 // offline vector), and the active mode. Ints are emitted plainly; strings are
@@ -493,73 +490,12 @@ bool ViewModeModel::operator==(const ViewModeModel& o) const {
namespace { namespace {
void writeEscaped(std::string& out, const std::string& s) { // Shared core/json emit helpers (Q-W1): same escape set + %d rendering as the
out += '"'; // prior file-local writer, so the emitted blob is byte-identical.
for (char c : s) { using json::writeEscaped;
switch (c) { using json::writeIntArray;
case '"': out += "\\\""; break; std::string intToStr(int v) { return json::numToStr(v); }
case '\\': out += "\\\\"; break; using ObjWriter = json::Writer;
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<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(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<int>& 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;
};
} // namespace } // namespace
@@ -660,250 +596,64 @@ std::string ViewModeModel::serialize() const {
namespace { namespace {
class Parser { // The model DOMAIN grammar over the shared core/json lexical layer (Q-W1).
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<std::string, TrackSnapshot>& snaps);
bool parseLanes(LaneOwnershipIndex& idx);
bool parseIntArray(std::vector<int>& 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<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(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<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(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<int>(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<int>& 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 registry starts seeded (Arrange + Design). Deserialization must reproduce the // The registry starts seeded (Arrange + Design). Deserialization must reproduce the
// serialized set exactly, so we replace the seeded contents with the parsed ones — // 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 // 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 // duplicates and the ordinals/names would not round-trip. We therefore parse into a
// fresh vector and swap. `reg` is passed empty (see parseModel). // fresh vector and swap. `reg` is passed empty (see parseModel).
bool Parser::parseModes(ModeRegistry& reg) { bool parseModes(json::Reader& r, ModeRegistry& reg) {
if (!consume('[')) return false; if (!r.consume('[')) return false;
skipWs(); r.skipWs();
if (consume(']')) return true; // empty array (unusual, but valid) if (r.consume(']')) return true; // empty array (unusual, but valid)
do { do {
if (!consume('{')) return false; if (!r.consume('{')) return false;
Mode m; Mode m;
bool haveId = false; bool haveId = false;
do { do {
std::string k; std::string k;
if (!parseKey(k)) return false; if (!r.parseKey(k)) return false;
if (k == "id") { if (!parseString(m.id)) return false; haveId = true; } if (k == "id") { if (!r.parseString(m.id)) return false; haveId = true; }
else if (k == "displayName") { if (!parseString(m.displayName)) return false; } else if (k == "displayName") { if (!r.parseString(m.displayName)) return false; }
else if (k == "ordinal") { if (!parseInt(m.ordinal)) return false; } else if (k == "ordinal") { if (!r.parseInt(m.ordinal)) return false; }
else if (!skipValue()) return false; else if (!r.skipValue()) return false;
} while (consume(',')); } while (r.consume(','));
if (!consume('}')) return false; if (!r.consume('}')) return false;
if (!haveId || !reg.add(m)) return false; // malformed / duplicate id if (!haveId || !reg.add(m)) return false; // malformed / duplicate id
} while (consume(',')); } while (r.consume(','));
return consume(']'); return r.consume(']');
} }
bool Parser::parseMembership(MembershipIndex& idx) { bool parseMembership(json::Reader& r, MembershipIndex& idx) {
if (!consume('[')) return false; if (!r.consume('[')) return false;
skipWs(); r.skipWs();
if (consume(']')) return true; if (r.consume(']')) return true;
do { do {
if (!consume('{')) return false; if (!r.consume('{')) return false;
std::string guid; std::string guid;
Membership mem; Membership mem;
bool haveGuid = false; bool haveGuid = false;
do { do {
std::string k; std::string k;
if (!parseKey(k)) return false; if (!r.parseKey(k)) return false;
if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; } if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
else if (k == "modes") { else if (k == "modes") {
if (!consume('[')) return false; if (!r.consume('[')) return false;
skipWs(); r.skipWs();
if (!consume(']')) { if (!r.consume(']')) {
do { do {
std::string id; std::string id;
if (!parseString(id)) return false; if (!r.parseString(id)) return false;
mem.modeIds.insert(id); mem.modeIds.insert(id);
} while (consume(',')); } while (r.consume(','));
if (!consume(']')) return false; if (!r.consume(']')) return false;
} }
} }
else if (k == "showBoth") { if (!parseBool(mem.showBoth)) return false; } else if (k == "showBoth") { if (!r.parseBool(mem.showBoth)) return false; }
else if (!skipValue()) return false; else if (!r.skipValue()) return false;
} while (consume(',')); } while (r.consume(','));
if (!consume('}')) return false; if (!r.consume('}')) return false;
if (!haveGuid || guid.empty()) return false; if (!haveGuid || guid.empty()) return false;
// Install the entry verbatim (tag() would clear a multi-mode set and drop // 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 // 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 // mode that no longer exists has an immediate behavioral consequence, so it
// is caught and the parse is rejected. // is caught and the parse is rejected.
if (!idx.restore(guid, mem)) return false; if (!idx.restore(guid, mem)) return false;
} while (consume(',')); } while (r.consume(','));
return consume(']'); return r.consume(']');
} }
bool Parser::parseSnapshots(std::map<std::string, TrackSnapshot>& snaps) { bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps) {
if (!consume('[')) return false; if (!r.consume('[')) return false;
skipWs(); r.skipWs();
if (consume(']')) return true; if (r.consume(']')) return true;
do { do {
if (!consume('{')) return false; if (!r.consume('{')) return false;
std::string guid; std::string guid;
TrackSnapshot snap; TrackSnapshot snap;
bool haveGuid = false; bool haveGuid = false;
do { do {
std::string k; std::string k;
if (!parseKey(k)) return false; if (!r.parseKey(k)) return false;
if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; } if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
else if (k == "showInTcp") { if (!parseInt(snap.showInTcp)) return false; } else if (k == "showInTcp") { if (!r.parseInt(snap.showInTcp)) return false; }
else if (k == "showInMixer") { if (!parseInt(snap.showInMixer)) return false; } else if (k == "showInMixer") { if (!r.parseInt(snap.showInMixer)) return false; }
else if (k == "mainSend") { if (!parseInt(snap.mainSend)) return false; } else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; }
else if (k == "fxEnable") { if (!parseInt(snap.fxEnable)) return false; } else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; }
else if (k == "fxOffline") { if (!parseIntArray(snap.fxOffline)) return false; } else if (k == "fxOffline") { if (!r.parseIntArray(snap.fxOffline)) return false; }
else if (!skipValue()) return false; else if (!r.skipValue()) return false;
} while (consume(',')); } while (r.consume(','));
if (!consume('}')) return false; if (!r.consume('}')) return false;
if (!haveGuid || guid.empty()) return false; if (!haveGuid || guid.empty()) return false;
snaps[guid] = snap; snaps[guid] = snap;
} while (consume(',')); } while (r.consume(','));
return consume(']'); return r.consume(']');
} }
bool Parser::parseLanes(LaneOwnershipIndex& idx) { bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) {
if (!consume('[')) return false; if (!r.consume('[')) return false;
skipWs(); r.skipWs();
if (consume(']')) return true; if (r.consume(']')) return true;
do { do {
if (!consume('{')) return false; if (!r.consume('{')) return false;
std::string trackGuid, laneKey, mode; std::string trackGuid, laneKey, mode;
bool haveTrack = false, haveLane = false, managed = false, haveManaged = false; bool haveTrack = false, haveLane = false, managed = false, haveManaged = false;
do { do {
std::string k; std::string k;
if (!parseKey(k)) return false; if (!r.parseKey(k)) return false;
if (k == "trackGuid") { if (!parseString(trackGuid)) return false; haveTrack = true; } if (k == "trackGuid") { if (!r.parseString(trackGuid)) return false; haveTrack = true; }
else if (k == "laneKey") { if (!parseString(laneKey)) return false; haveLane = true; } else if (k == "laneKey") { if (!r.parseString(laneKey)) return false; haveLane = true; }
else if (k == "managed") { if (!parseBool(managed)) return false; haveManaged = true; } else if (k == "managed") { if (!r.parseBool(managed)) return false; haveManaged = true; }
else if (k == "mode") { if (!parseString(mode)) return false; } else if (k == "mode") { if (!r.parseString(mode)) return false; }
else if (!skipValue()) return false; else if (!r.skipValue()) return false;
} while (consume(',')); } while (r.consume(','));
if (!consume('}')) return false; if (!r.consume('}')) return false;
// Both keys mandatory and non-empty (they form the lane's identity). A managed // 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 // 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 // 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 (!mode.empty()) return false; // manual lane must not carry a mode
if (!idx.setManual(trackGuid, laneKey)) return false; if (!idx.setManual(trackGuid, laneKey)) return false;
} }
} while (consume(',')); } while (r.consume(','));
return consume(']'); return r.consume(']');
} }
bool Parser::parseModel(ViewModeModel& out) { bool parseModel(json::Reader& r, ViewModeModel& out) {
if (!consume('{')) return false; if (!r.consume('{')) return false;
skipWs(); r.skipWs();
if (consume('}')) return true; // lenient empty root ⇒ default-seeded model if (r.consume('}')) return true; // lenient empty root ⇒ default-seeded model
ModeRegistry reg; // seeded default; REPLACED if a modes array is present ModeRegistry reg; // seeded default; REPLACED if a modes array is present
bool haveModes = false; bool haveModes = false;
@@ -999,33 +749,33 @@ bool Parser::parseModel(ViewModeModel& out) {
do { do {
std::string key; std::string key;
if (!parseKey(key)) return false; if (!r.parseKey(key)) return false;
if (key == "activeMode") { if (key == "activeMode") {
if (!parseString(activeMode)) return false; if (!r.parseString(activeMode)) return false;
haveActive = true; haveActive = true;
} else if (key == "modes") { } else if (key == "modes") {
ModeRegistry fresh = ModeRegistry::makeEmpty(); // parse into empty, then own ModeRegistry fresh = ModeRegistry::makeEmpty(); // parse into empty, then own
if (!parseModes(fresh)) return false; if (!parseModes(r, fresh)) return false;
reg = fresh; reg = fresh;
haveModes = true; haveModes = true;
} else if (key == "membership") { } else if (key == "membership") {
if (!parseMembership(membership)) return false; if (!parseMembership(r, membership)) return false;
} else if (key == "snapshots") { } else if (key == "snapshots") {
if (!parseSnapshots(snaps)) return false; if (!parseSnapshots(r, snaps)) return false;
} else if (key == "lanes") { } else if (key == "lanes") {
if (!parseLanes(lanes)) return false; if (!parseLanes(r, lanes)) return false;
} else { } else {
// Unknown keys and the "version" field are skipped here. // Unknown keys and the "version" field are skipped here.
// "version" is serialized as a forward-compat placeholder — there is no // "version" is serialized as a forward-compat placeholder — there is no
// active version gate yet; all persisted data is parsed the same way // 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. // 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; if (!r.consume('}')) return false;
skipWs(); r.skipWs();
if (!eof()) return false; // trailing garbage if (!r.eof()) return false; // trailing garbage
if (haveModes) out.modes() = reg; if (haveModes) out.modes() = reg;
out.membership() = membership; out.membership() = membership;
@@ -1039,10 +789,10 @@ bool Parser::parseModel(ViewModeModel& out) {
} // namespace } // namespace
std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string& json) { std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string& blob) {
ViewModeModel vm; ViewModeModel vm;
Parser p(json); json::Reader r(blob);
if (!p.parseModel(vm)) return std::nullopt; if (!parseModel(r, vm)) return std::nullopt;
return vm; return vm;
} }
+7 -14
View File
@@ -3,27 +3,20 @@
#include "bank_sync.h" #include "bank_sync.h"
#include <cstdint> #include <cstdint>
#include <limits>
#include <string> #include <string>
#include "core/wire/wire.h"
namespace reasampler::vst { namespace reasampler::vst {
std::int64_t parseBankGeneration(const std::string& raw) { std::int64_t parseBankGeneration(const std::string& raw) {
if (raw.empty()) return kBankGenerationAbsent; // Whole-string, non-negative decimal parse WITHOUT exceptions or locale
// surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale surprises. // '+' / '-', any non-digit, an empty string, or overflow past int64 max all
// A leading '+' / '-' , any non-digit, an empty digit run, or overflow past int64 max // reject to the absent default (0); the guarded accumulate means a
// all reject to the absent default (0). Manual accumulation with an overflow guard so a
// pathologically long digit run can never wrap into a bogus small value. // pathologically long digit run can never wrap into a bogus small value.
std::int64_t value = 0; std::int64_t value = 0;
constexpr std::int64_t kMax = std::numeric_limits<std::int64_t>::max(); if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
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;
}
return value; return value;
} }
+3 -10
View File
@@ -15,6 +15,7 @@
#include "capture_browser.h" #include "capture_browser.h"
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) #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 "curve_popup.h" // r11 centered curve-popup sheet geometry (FB1)
#include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3) #include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3)
#include "editor_geometry.h" // Rect, contains #include "editor_geometry.h" // Rect, contains
@@ -770,16 +771,8 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
if (!relativePath.empty()) { if (!relativePath.empty()) {
const std::string projectDir = processor_->bridge().activeProjectDir(); const std::string projectDir = processor_->bridge().activeProjectDir();
const std::string abs = resolveBankFile(projectDir, relativePath); const std::string abs = resolveBankFile(projectDir, relativePath);
std::vector<std::uint8_t> bytes; // Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure.
std::ifstream f(abs, std::ios::binary | std::ios::ate); const std::vector<std::uint8_t> bytes = readFileBytes(abs);
if (f) {
const std::streamoff size = f.tellg();
if (size > 0) {
f.seekg(0, std::ios::beg);
bytes.resize(static_cast<std::size_t>(size));
if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
}
}
const WavLayout layout = parseWavLayout(bytes); const WavLayout layout = parseWavLayout(bytes);
if (layout.valid) { if (layout.valid) {
std::vector<AudioSample> interleaved = std::vector<AudioSample> interleaved =
+4 -13
View File
@@ -20,6 +20,7 @@
#include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) #include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse)
#include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision #include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #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 "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract)
#include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) #include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
#include "reasampler_editor.h" #include "reasampler_editor.h"
@@ -69,19 +70,9 @@ std::string mintUsageInstanceGuid() {
return std::string(buf); return std::string(buf);
} }
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on // Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03).
// any failure — the caller treats an unreadable WAV as "nothing to play". // Off-thread only (blocking file I/O). Empty on any failure — the caller treats
std::vector<std::uint8_t> readFileBytes(const std::string& path) { // an unreadable WAV as "nothing to play".
std::vector<std::uint8_t> 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<std::size_t>(size));
if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
return bytes;
}
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file // 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 // I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode
+51
View File
@@ -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 <cstdio>
#include <fstream>
#include <string>
#include <vector>
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<std::uint8_t> payload = {0x00, 0x01, 0xFF, 0x7E, 0x00, 0x0A};
{
std::ofstream f(kScratch, std::ios::binary | std::ios::trunc);
f.write(reinterpret_cast<const char*>(payload.data()),
static_cast<std::streamsize>(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;
}
+296
View File
@@ -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 <climits>
#include <cstdio>
#include <string>
#include <vector>
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<std::int64_t>(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<std::string> 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<std::string> v;
CHECK(r.parseStringArray(v)); CHECK(v.empty());
}
{
const std::string s = "[1,2]"; // non-string element
json::Reader r(s);
std::vector<std::string> v;
CHECK(!r.parseStringArray(v));
}
{
const std::string s = "[1,2,3]";
json::Reader r(s);
std::vector<int> v;
CHECK(r.parseIntArray(v));
CHECK(v.size() == 3 && v[2] == 3);
}
{
const std::string s = "[1,"; // truncated
json::Reader r(s);
std::vector<int> 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;
}
+15
View File
@@ -135,6 +135,20 @@ static void testRoundTripAuto() {
CHECK(back && settingsEqual(*back, s)); 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() { static void testDeserializeEmptyIsDefault() {
// An absent/empty stored value (older project) -> nullopt, so the caller falls // 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. // back to the default. This is the graceful-old-project path the brief requires.
@@ -164,6 +178,7 @@ int main() {
testRoundTripNoneDefault(); testRoundTripNoneDefault();
testRoundTripManualArbitraryMs(); testRoundTripManualArbitraryMs();
testRoundTripAuto(); testRoundTripAuto();
testSerializeByteIdentity();
testDeserializeEmptyIsDefault(); testDeserializeEmptyIsDefault();
testDeserializeMalformedIsDefault(); testDeserializeMalformedIsDefault();
+190
View File
@@ -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 <cstdio>
#include <string>
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;
}