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:
+46
-2
@@ -82,12 +82,34 @@ set(SDK_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/reaper-sdk/sdk)
|
||||
set(WDL_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/WDL/WDL)
|
||||
set(SWELL ${WDL_INC}/swell)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 0) core/ — the Q-W1 shared pure substrate. NO REAPER, NO SWELL, NO VST3.
|
||||
# json: the ONE JSON lexical layer (reader + writer) behind the five
|
||||
# persisted-blob (de)serializers (bank_model / bank_book /
|
||||
# view_mode_model / owned_manifest / tail_control). T2-02 / §2.
|
||||
# wire: the ONE length-prefixed ext-state wire codec (putField + Cursor +
|
||||
# the guarded decimal accumulate) behind provenance /
|
||||
# assignment_request / sample_usage / bank_sync. T2-01(b).
|
||||
# file_bytes: the ONE whole-file byte loader both artifacts link. T2-03.
|
||||
# Headers are included as "core/json/json.h" etc. (rooted at src/), so the
|
||||
# include paths survive the Q-W1 part-2 directory relocation unchanged.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(json STATIC src/core/json/json.cpp)
|
||||
target_include_directories(json PUBLIC src)
|
||||
|
||||
add_library(wire STATIC src/core/wire/wire.cpp)
|
||||
target_include_directories(wire PUBLIC src)
|
||||
|
||||
add_library(file_bytes STATIC src/core/util/file_bytes.cpp)
|
||||
target_include_directories(file_bytes PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1) Pure model library — NO REAPER, NO SWELL. Builds & tests anywhere.
|
||||
# The sampler's heart: Sample metadata + BankIndex (Milestone 1).
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(bank_model STATIC src/bank_model.cpp)
|
||||
target_include_directories(bank_model PUBLIC src)
|
||||
target_link_libraries(bank_model PRIVATE json)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2) Pure peaks library — NO REAPER, NO SWELL. Waveform min/max thumbnails from
|
||||
@@ -142,6 +164,7 @@ target_include_directories(tab_strip PUBLIC src)
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(view_mode_model STATIC src/view_mode_model.cpp)
|
||||
target_include_directories(view_mode_model PUBLIC src)
|
||||
target_link_libraries(view_mode_model PRIVATE json)
|
||||
# The pure lane-minting decision (planLaneMinting) names managed lanes via the ONE
|
||||
# durable-key convention in lane_keys (laneNameForMode), so the model depends on that
|
||||
# pure sibling. PUBLIC so every consumer (tests + module) resolves the symbol.
|
||||
@@ -224,6 +247,7 @@ target_include_directories(batch_capture PUBLIC src)
|
||||
add_library(tail_control STATIC src/tail_control.cpp)
|
||||
target_include_directories(tail_control PUBLIC src)
|
||||
target_link_libraries(tail_control PUBLIC render_settings)
|
||||
target_link_libraries(tail_control PRIVATE json)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2g') Pure bank_book library — NO REAPER, NO SWELL. The multi-bank phase heart
|
||||
@@ -236,6 +260,7 @@ target_link_libraries(tail_control PUBLIC render_settings)
|
||||
add_library(bank_book STATIC src/bank_book.cpp)
|
||||
target_include_directories(bank_book PUBLIC src)
|
||||
target_link_libraries(bank_book PUBLIC bank_model)
|
||||
target_link_libraries(bank_book PRIVATE json)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2g'') Pure owned_manifest library — NO REAPER, NO SWELL. The owned-file manifest
|
||||
@@ -248,6 +273,7 @@ target_link_libraries(bank_book PUBLIC bank_model)
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(owned_manifest STATIC src/owned_manifest.cpp)
|
||||
target_include_directories(owned_manifest PUBLIC src)
|
||||
target_link_libraries(owned_manifest PRIVATE json)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2g''') Pure prune_reconcile library — NO REAPER, NO SWELL, NO filesystem. The
|
||||
@@ -323,6 +349,7 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(provenance STATIC src/provenance.cpp)
|
||||
target_include_directories(provenance PUBLIC src)
|
||||
target_link_libraries(provenance PRIVATE wire)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2j') Pure assignment_request library — NO REAPER, NO SWELL, NO VST3. The S8 ingest
|
||||
@@ -336,6 +363,7 @@ target_include_directories(provenance PUBLIC src)
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(assignment_request STATIC src/assignment_request.cpp)
|
||||
target_include_directories(assignment_request PUBLIC src)
|
||||
target_link_libraries(assignment_request PRIVATE wire)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2j'') Pure sample_usage library — NO REAPER, NO SWELL, NO VST3. The pS-usage seam:
|
||||
@@ -349,6 +377,7 @@ target_include_directories(assignment_request PUBLIC src)
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(sample_usage STATIC src/sample_usage.cpp)
|
||||
target_include_directories(sample_usage PUBLIC src)
|
||||
target_link_libraries(sample_usage PRIVATE wire)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2l) Pure drag_out library — NO REAPER, NO SWELL, NO OS/OLE. The Milestone 11
|
||||
@@ -538,6 +567,20 @@ target_link_libraries(sampler_core PUBLIC peaks pitch_shift velocity_curve)
|
||||
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
||||
# ---------------------------------------------------------------------------
|
||||
enable_testing()
|
||||
|
||||
# core/ (Q-W1): the shared JSON lexical layer, wire codec, and file loader.
|
||||
add_executable(json_tests tests/test_json.cpp)
|
||||
target_link_libraries(json_tests PRIVATE json)
|
||||
add_test(NAME json_tests COMMAND json_tests)
|
||||
|
||||
add_executable(wire_tests tests/test_wire.cpp)
|
||||
target_link_libraries(wire_tests PRIVATE wire)
|
||||
add_test(NAME wire_tests COMMAND wire_tests)
|
||||
|
||||
add_executable(file_bytes_tests tests/test_file_bytes.cpp)
|
||||
target_link_libraries(file_bytes_tests PRIVATE file_bytes)
|
||||
add_test(NAME file_bytes_tests COMMAND file_bytes_tests)
|
||||
|
||||
add_executable(bank_model_tests tests/test_bank_model.cpp)
|
||||
target_link_libraries(bank_model_tests PRIVATE bank_model)
|
||||
add_test(NAME bank_model_tests COMMAND bank_model_tests)
|
||||
@@ -822,6 +865,7 @@ target_link_libraries(waveform_view PUBLIC editor_geometry peaks)
|
||||
add_library(bank_sync STATIC src/vst/bank_sync.cpp)
|
||||
target_include_directories(bank_sync PUBLIC src/vst src)
|
||||
target_link_libraries(bank_sync PUBLIC assignment_request)
|
||||
target_link_libraries(bank_sync PRIVATE wire)
|
||||
|
||||
# browser_scroll (Phase S12) — PURE scroll-window + scrollbar-thumb + type-to-filter-search
|
||||
# geometry LAYERED over the S10 capture_browser: the visible-card window, thumb rect +
|
||||
@@ -1039,7 +1083,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/card_drag.cpp
|
||||
src/usage_scan.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
|
||||
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
|
||||
@@ -1201,7 +1245,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
|
||||
waveform_view bank_sync browser_scroll note_entry param_slider
|
||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
||||
knob_deck curve_popup master_gain sample_usage)
|
||||
knob_deck curve_popup master_gain sample_usage file_bytes)
|
||||
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
|
||||
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
|
||||
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
|
||||
+6
-110
@@ -2,8 +2,7 @@
|
||||
|
||||
#include "assignment_request.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
#include "core/wire/wire.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -11,114 +10,11 @@ namespace {
|
||||
|
||||
constexpr const char* kMagic = "rsassign1";
|
||||
|
||||
// Append one length-prefixed field: <decimal-len> ':' <bytes>. Mirror of
|
||||
// provenance's putField so the two seams share one wire idiom.
|
||||
void putField(std::string& out, const std::string& field) {
|
||||
out += std::to_string(field.size());
|
||||
out += ':';
|
||||
out += field;
|
||||
}
|
||||
|
||||
// Cursor over the encoded string. All reads are bounds-checked; a short read fails
|
||||
// the whole parse (ok_ latches false). Mirror of provenance's Cursor, trimmed to the
|
||||
// three field kinds this record needs.
|
||||
class Cursor {
|
||||
public:
|
||||
explicit Cursor(const std::string& s) : s_(s) {}
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
bool atEnd() const { return pos_ >= s_.size(); }
|
||||
|
||||
// Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or
|
||||
// non-numeric length, a length that overflows SIZE_MAX, or a length that runs past
|
||||
// the end. The digit count is capped at 20 (the decimal width of SIZE_MAX on a
|
||||
// 64-bit host) so a crafted 200-digit length cannot accumulate past SIZE_MAX via
|
||||
// repeated multiply. "never UB" promise from the header is upheld here.
|
||||
bool field(std::string& out) {
|
||||
if (!ok_) return false;
|
||||
const std::size_t colon = s_.find(':', pos_);
|
||||
if (colon == std::string::npos) return fail();
|
||||
if (colon == pos_) return fail(); // empty length token
|
||||
// Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus.
|
||||
if (colon - pos_ > 20u) return fail();
|
||||
std::size_t len = 0;
|
||||
for (std::size_t i = pos_; i < colon; ++i) {
|
||||
const char c = s_[i];
|
||||
if (c < '0' || c > '9') return fail();
|
||||
const std::size_t digit = static_cast<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;
|
||||
};
|
||||
// The shared core/wire codec (Q-W1, T2-01b) — the same field grammar + hardening
|
||||
// this file previously carried as its own Cursor copy. "never UB, never a
|
||||
// partial value" is upheld in the codec.
|
||||
using wire::putField;
|
||||
using Cursor = wire::Cursor;
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
+68
-306
@@ -1,14 +1,14 @@
|
||||
#include "bank_book.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// bank_book implementation.
|
||||
//
|
||||
// JSON is hand-rolled and self-contained, matching the house style of bank_model
|
||||
// and view_mode_model (brief: keep the pure core dependency-free — no third-party
|
||||
// JSON lib). The book blob nests one bank object per bank, each carrying that
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model
|
||||
// and view_mode_model. The book blob nests one bank object per bank, each carrying that
|
||||
// bank's BankIndex serialized by bank_model's OWN writer (BankIndex::serialize),
|
||||
// so per-bank sample serialization stays owned by bank_model and is not duplicated
|
||||
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
|
||||
@@ -138,7 +138,7 @@ SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pai
|
||||
}
|
||||
|
||||
// SlotMap::serialize is defined in the JSON writer section below (it reuses the
|
||||
// file-local ObjWriter / intToStr helpers).
|
||||
// shared core/json emit helpers).
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BankBook — construction + bank lookup
|
||||
@@ -550,64 +550,10 @@ std::vector<std::string> BankBook::referencedPaths() const {
|
||||
|
||||
namespace {
|
||||
|
||||
void writeEscaped(std::string& out, const std::string& s) {
|
||||
out += '"';
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<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;
|
||||
};
|
||||
// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the
|
||||
// prior file-local writer carried, so the emitted blob is byte-identical.
|
||||
std::string intToStr(int v) { return json::numToStr(v); }
|
||||
using ObjWriter = json::Writer;
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -660,223 +606,38 @@ std::string BankBook::serialize() const {
|
||||
|
||||
namespace {
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const std::string& s) : s_(s) {}
|
||||
// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1).
|
||||
// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot},
|
||||
// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive
|
||||
// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root
|
||||
// blob, distinguishing the legacy shape (a bare bank_index object: has
|
||||
// "samples", no "banks") from the book shape (has "banks"): a legacy blob
|
||||
// yields a single pool bank carrying the migrated index and an empty active id
|
||||
// (⇒ pool). The member deserialize() adopts the result (ordinal normalize +
|
||||
// active resolve).
|
||||
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out);
|
||||
|
||||
// Parses a book blob into a bank set + active id. On success fills the out-params
|
||||
// and returns true. Distinguishes the legacy shape (a bare bank_index object: has
|
||||
// "samples", no "banks") from the book shape (has "banks"): a legacy blob yields a
|
||||
// single pool bank carrying the migrated index and an empty active id (⇒ pool). The
|
||||
// member deserialize() adopts the result (ordinal normalize + active resolve).
|
||||
bool parseBook(std::vector<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 parseBank(json::Reader& r, Bank& b) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return false; // a bank object must at least carry an id
|
||||
|
||||
bool haveId = false;
|
||||
bool haveIndex = false;
|
||||
do {
|
||||
std::string key;
|
||||
if (!parseKey(key)) return false;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "id") {
|
||||
if (!parseString(b.id)) return false;
|
||||
if (!r.parseString(b.id)) return false;
|
||||
haveId = true;
|
||||
} else if (key == "displayName") {
|
||||
if (!parseString(b.displayName)) return false;
|
||||
if (!r.parseString(b.displayName)) return false;
|
||||
} else if (key == "ordinal") {
|
||||
if (!parseInt(b.ordinal)) return false;
|
||||
if (!r.parseInt(b.ordinal)) return false;
|
||||
} else if (key == "index") {
|
||||
std::string raw;
|
||||
if (!captureValue(raw)) return false;
|
||||
if (!r.captureValue(raw)) return false;
|
||||
auto idx = BankIndex::deserialize(raw);
|
||||
if (!idx) return false; // a malformed nested index fails the whole parse
|
||||
b.index = std::move(*idx);
|
||||
@@ -886,49 +647,50 @@ bool Parser::parseBank(Bank& b) {
|
||||
// nothing because the key never appears); when present it drives the
|
||||
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership.
|
||||
std::vector<std::pair<std::string, int>> pairs;
|
||||
if (!parseSlots(pairs)) return false;
|
||||
if (!parseSlots(r, pairs)) return false;
|
||||
b.slots = SlotMap::fromEntries(pairs);
|
||||
} else {
|
||||
if (!skipValue()) return false; // forward-compat unknown keys
|
||||
if (!r.skipValue()) return false; // forward-compat unknown keys
|
||||
}
|
||||
} while (consume(','));
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!consume('}')) return false;
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveId || b.id.empty()) return false; // id keys the registry
|
||||
if (!haveIndex) return false; // every bank persists its index
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Parser::parseSlots(std::vector<std::pair<std::string, int>>& out) {
|
||||
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out) {
|
||||
out.clear();
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true; // empty slot array — a bank with no positions yet
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true; // empty slot array — a bank with no positions yet
|
||||
do {
|
||||
if (!consume('{')) return false;
|
||||
if (!r.consume('{')) return false;
|
||||
std::string id;
|
||||
int slot = 0;
|
||||
bool haveId = false, haveSlot = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!parseKey(k)) return false;
|
||||
if (k == "id") { if (!parseString(id)) return false; haveId = true; }
|
||||
else if (k == "slot") { if (!parseInt(slot)) return false; haveSlot = true; }
|
||||
else { if (!skipValue()) return false; } // forward-compat
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
|
||||
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
|
||||
else { if (!r.skipValue()) return false; } // forward-compat
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveId || !haveSlot) return false; // a slot entry needs both
|
||||
out.emplace_back(std::move(id), slot);
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseBook(std::vector<Bank>& banks, std::string& activeBank) {
|
||||
bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks,
|
||||
std::string& activeBank) {
|
||||
banks.clear();
|
||||
activeBank.clear();
|
||||
if (!consume('{')) return false;
|
||||
skipWs();
|
||||
if (consume('}')) return false; // an empty object is neither shape → malformed
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return false; // an empty object is neither shape → malformed
|
||||
|
||||
// Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a
|
||||
// "samples" key with no "banks" ⇒ legacy shape (promote into the pool).
|
||||
@@ -938,41 +700,41 @@ bool Parser::parseBook(std::vector<Bank>& banks, std::string& activeBank) {
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
if (!parseKey(key)) return false;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "banks") {
|
||||
sawBanks = true;
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (!consume(']')) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (!r.consume(']')) {
|
||||
do {
|
||||
Bank b;
|
||||
if (!parseBank(b)) return false;
|
||||
if (!parseBank(r, b)) return false;
|
||||
parsedBanks.push_back(std::move(b));
|
||||
} while (consume(','));
|
||||
if (!consume(']')) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume(']')) return false;
|
||||
}
|
||||
} else if (key == "activeBank") {
|
||||
if (!parseString(activeBank)) return false;
|
||||
if (!r.parseString(activeBank)) return false;
|
||||
} else if (key == "samples") {
|
||||
// Legacy marker. The legacy index is re-parsed from the whole input below
|
||||
// (BankIndex::deserialize owns that shape); here we only skip the value to
|
||||
// keep the scan well-formed and note that we saw it.
|
||||
sawSamples = true;
|
||||
if (!skipValue()) return false;
|
||||
if (!r.skipValue()) return false;
|
||||
} else {
|
||||
if (!skipValue()) return false; // version, or unknown
|
||||
if (!r.skipValue()) return false; // version, or unknown
|
||||
}
|
||||
} while (consume(','));
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!consume('}')) return false;
|
||||
skipWs();
|
||||
if (!eof()) return false; // trailing garbage
|
||||
if (!r.consume('}')) return false;
|
||||
r.skipWs();
|
||||
if (!r.eof()) return false; // trailing garbage
|
||||
|
||||
// --- Legacy migration: a bare bank_index (samples, no banks) → pool. ---
|
||||
if (!sawBanks) {
|
||||
if (!sawSamples) return false; // neither shape's marker → malformed
|
||||
auto legacy = BankIndex::deserialize(s_);
|
||||
auto legacy = BankIndex::deserialize(raw);
|
||||
if (!legacy) return false;
|
||||
Bank pool;
|
||||
pool.id = kPoolBankId;
|
||||
@@ -1059,11 +821,11 @@ void BankBook::adoptBanks(std::vector<Bank>&& banks, const std::string& activeBa
|
||||
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::string activeBank;
|
||||
Parser p(json);
|
||||
if (!p.parseBook(banks, activeBank)) return std::nullopt;
|
||||
json::Reader r(blob);
|
||||
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt;
|
||||
|
||||
BankBook book;
|
||||
book.adoptBanks(std::move(banks), activeBank);
|
||||
|
||||
+89
-395
@@ -1,17 +1,15 @@
|
||||
#include "bank_model.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// bank_model implementation.
|
||||
//
|
||||
// JSON is hand-rolled and self-contained (brief: keep the pure core
|
||||
// dependency-free — no third-party JSON lib, no WDL coupling). The field set is
|
||||
// a flat struct of primitives, strings, one enum, a small string array, and a
|
||||
// few optionals, so a compact writer + recursive-descent parser is the simplest
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer,
|
||||
// no per-module Parser copy). The field set is a flat struct of primitives,
|
||||
// strings, one enum, a small string array, and a few optionals, so a compact
|
||||
// writer + recursive-descent DOMAIN parser over json::Reader is the simplest
|
||||
// thing that works. Doubles are emitted with 17 significant digits (%.17g), the
|
||||
// shortest form that round-trips every IEEE-754 double exactly, so the
|
||||
// deserialize(serialize(x)) == x invariant holds bit-for-bit.
|
||||
@@ -147,84 +145,10 @@ std::vector<Sample> BankIndex::byTier(Tier tier) const {
|
||||
|
||||
namespace {
|
||||
|
||||
void writeEscaped(std::string& out, const std::string& s) {
|
||||
out += '"';
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<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 += ']';
|
||||
}
|
||||
using json::numToStr;
|
||||
using json::writeEscaped;
|
||||
using json::writeStringArray;
|
||||
using ObjWriter = json::Writer;
|
||||
|
||||
void writeSample(std::string& out, const Sample& s) {
|
||||
ObjWriter w(out);
|
||||
@@ -317,346 +241,116 @@ std::string BankIndex::serialize() const {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON parser (recursive descent). Returns false on any malformed input; never
|
||||
// reads out of bounds. Only supports the subset our writer emits.
|
||||
// JSON parser (recursive descent over the shared json::Reader). Returns false
|
||||
// on any malformed input; never reads out of bounds. Only supports the subset
|
||||
// our writer emits. The lexical layer (strings, numbers, skip) lives in
|
||||
// core/json; only the Sample/index DOMAIN grammar lives here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const std::string& s) : s_(s) {}
|
||||
|
||||
bool parseIndex(BankIndex& out);
|
||||
|
||||
private:
|
||||
const std::string& s_;
|
||||
std::size_t pos_ = 0;
|
||||
|
||||
bool eof() const { return pos_ >= s_.size(); }
|
||||
char peek() const { return s_[pos_]; }
|
||||
|
||||
void skipWs() {
|
||||
while (!eof()) {
|
||||
char c = s_[pos_];
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
bool consume(char c) {
|
||||
skipWs();
|
||||
if (eof() || s_[pos_] != c) return false;
|
||||
++pos_;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseString(std::string& out);
|
||||
bool parseRawScalar(std::string& out); // number / true / false / null token
|
||||
bool parseDouble(double& out);
|
||||
bool parseInt64(std::int64_t& out);
|
||||
bool parseInt(int& out);
|
||||
bool parseBool(bool& out);
|
||||
bool expectNullOr(bool& wasNull); // peeks for `null`; consumes if present
|
||||
|
||||
bool parseSample(Sample& out);
|
||||
bool parseKey(std::string& key); // an object member key + ':'
|
||||
bool skipValue(); // for forward-compat unknown keys
|
||||
};
|
||||
|
||||
// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX
|
||||
// for control chars). Positioned at the opening quote after whitespace.
|
||||
bool Parser::parseString(std::string& out) {
|
||||
skipWs();
|
||||
if (eof() || s_[pos_] != '"') return false;
|
||||
++pos_;
|
||||
out.clear();
|
||||
while (!eof()) {
|
||||
char c = s_[pos_++];
|
||||
if (c == '"') return true;
|
||||
if (c == '\\') {
|
||||
if (eof()) return false;
|
||||
char e = s_[pos_++];
|
||||
switch (e) {
|
||||
case '"': out += '"'; break;
|
||||
case '\\': out += '\\'; break;
|
||||
case '/': out += '/'; break;
|
||||
case 'b': out += '\b'; break;
|
||||
case 'f': out += '\f'; break;
|
||||
case 'n': out += '\n'; break;
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
// Decode a \uXXXX escape to its code point.
|
||||
auto readHex4 = [&](unsigned int& cp) -> bool {
|
||||
if (pos_ + 4 > s_.size()) return false;
|
||||
cp = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
char h = s_[pos_++];
|
||||
cp <<= 4;
|
||||
if (h >= '0' && h <= '9') cp |= static_cast<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)
|
||||
bool parseSample(json::Reader& r, Sample& s) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true; // empty object (shouldn't happen, but valid)
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
if (!parseKey(key)) return false;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "id") {
|
||||
if (!parseString(s.id)) return false;
|
||||
if (!r.parseString(s.id)) return false;
|
||||
} else if (key == "displayName") {
|
||||
if (!parseString(s.displayName)) return false;
|
||||
if (!r.parseString(s.displayName)) return false;
|
||||
} else if (key == "relativePath") {
|
||||
if (!parseString(s.relativePath)) return false;
|
||||
if (!r.parseString(s.relativePath)) return false;
|
||||
} else if (key == "sourceMode") {
|
||||
int v = 0;
|
||||
if (!parseInt(v)) return false;
|
||||
if (!r.parseInt(v)) return false;
|
||||
// Valid range: MasterMix(0) .. Realtime(5).
|
||||
if (v < static_cast<int>(SourceMode::MasterMix) ||
|
||||
v > static_cast<int>(SourceMode::Realtime))
|
||||
return false;
|
||||
s.sourceMode = static_cast<SourceMode>(v);
|
||||
} else if (key == "sourceRange") {
|
||||
if (!consume('{')) return false;
|
||||
if (!r.consume('{')) return false;
|
||||
do {
|
||||
std::string rk;
|
||||
if (!parseKey(rk)) return false;
|
||||
if (!r.parseKey(rk)) return false;
|
||||
double dv = 0.0;
|
||||
if (!parseDouble(dv)) return false;
|
||||
if (!r.parseDouble(dv)) return false;
|
||||
if (rk == "startSeconds") s.sourceRange.startSeconds = dv;
|
||||
else if (rk == "endSeconds") s.sourceRange.endSeconds = dv;
|
||||
else if (rk == "startPpq") s.sourceRange.startPpq = dv;
|
||||
else if (rk == "endPpq") s.sourceRange.endPpq = dv;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
} else if (key == "trackGuids") {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (!consume(']')) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (!r.consume(']')) {
|
||||
do {
|
||||
std::string g;
|
||||
if (!parseString(g)) return false;
|
||||
if (!r.parseString(g)) return false;
|
||||
s.trackGuids.push_back(g);
|
||||
} while (consume(','));
|
||||
if (!consume(']')) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume(']')) return false;
|
||||
}
|
||||
} else if (key == "wetDry") {
|
||||
if (!parseDouble(s.wetDry)) return false;
|
||||
if (!r.parseDouble(s.wetDry)) return false;
|
||||
} else if (key == "channelCount") {
|
||||
if (!parseInt(s.channelCount)) return false;
|
||||
if (!r.parseInt(s.channelCount)) return false;
|
||||
} else if (key == "sampleRate") {
|
||||
if (!parseInt(s.sampleRate)) return false;
|
||||
if (!r.parseInt(s.sampleRate)) return false;
|
||||
} else if (key == "lengthSeconds") {
|
||||
if (!parseDouble(s.lengthSeconds)) return false;
|
||||
if (!r.parseDouble(s.lengthSeconds)) return false;
|
||||
} else if (key == "lengthBeats") {
|
||||
if (!parseDouble(s.lengthBeats)) return false;
|
||||
if (!r.parseDouble(s.lengthBeats)) return false;
|
||||
} else if (key == "captureTempo") {
|
||||
if (!parseDouble(s.captureTempo)) return false;
|
||||
if (!r.parseDouble(s.captureTempo)) return false;
|
||||
} else if (key == "captureTimeSigNum") {
|
||||
if (!parseInt(s.captureTimeSigNum)) return false;
|
||||
if (!r.parseInt(s.captureTimeSigNum)) return false;
|
||||
} else if (key == "captureTimeSigDenom") {
|
||||
if (!parseInt(s.captureTimeSigDenom)) return false;
|
||||
if (!r.parseInt(s.captureTimeSigDenom)) return false;
|
||||
} else if (key == "key") {
|
||||
bool wasNull = false;
|
||||
if (!expectNullOr(wasNull)) return false;
|
||||
if (!r.expectNullOr(wasNull)) return false;
|
||||
if (wasNull) {
|
||||
s.key.reset();
|
||||
} else {
|
||||
std::string k;
|
||||
if (!parseString(k)) return false;
|
||||
if (!r.parseString(k)) return false;
|
||||
s.key = k;
|
||||
}
|
||||
} else if (key == "rootNote") {
|
||||
bool wasNull = false;
|
||||
if (!expectNullOr(wasNull)) return false;
|
||||
if (!r.expectNullOr(wasNull)) return false;
|
||||
if (wasNull) {
|
||||
s.rootNote.reset();
|
||||
} else {
|
||||
int v = 0;
|
||||
if (!parseInt(v)) return false;
|
||||
if (!r.parseInt(v)) return false;
|
||||
// Valid MIDI note range: 0..127 inclusive (boundaries valid).
|
||||
if (v < 0 || v > 127) return false;
|
||||
s.rootNote = v;
|
||||
}
|
||||
} else if (key == "loop") {
|
||||
bool wasNull = false;
|
||||
if (!expectNullOr(wasNull)) return false;
|
||||
if (!r.expectNullOr(wasNull)) return false;
|
||||
if (wasNull) {
|
||||
s.loop.reset();
|
||||
} else {
|
||||
if (!consume('{')) return false;
|
||||
if (!r.consume('{')) return false;
|
||||
LoopPoints lp;
|
||||
do {
|
||||
std::string lk;
|
||||
if (!parseKey(lk)) return false;
|
||||
if (!r.parseKey(lk)) return false;
|
||||
std::int64_t lv = 0;
|
||||
if (!parseInt64(lv)) return false;
|
||||
if (!r.parseInt64(lv)) return false;
|
||||
if (lk == "start") lp.start = lv;
|
||||
else if (lk == "end") lp.end = lv;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
// Invariant: 0 <= start <= end. start == end is a valid zero-length
|
||||
// marker; a negative index or start > end is malformed, not silently
|
||||
// clamped (mirrors the enum-range rejection above).
|
||||
@@ -664,89 +358,89 @@ bool Parser::parseSample(Sample& s) {
|
||||
s.loop = lp;
|
||||
}
|
||||
} else if (key == "levels") {
|
||||
if (!consume('{')) return false;
|
||||
if (!r.consume('{')) return false;
|
||||
do {
|
||||
std::string lk;
|
||||
if (!parseKey(lk)) return false;
|
||||
if (!r.parseKey(lk)) return false;
|
||||
double dv = 0.0;
|
||||
if (!parseDouble(dv)) return false;
|
||||
if (!r.parseDouble(dv)) return false;
|
||||
if (lk == "peakDb") s.levels.peakDb = dv;
|
||||
else if (lk == "rmsDb") s.levels.rmsDb = dv;
|
||||
else if (lk == "lufs") s.levels.lufs = dv;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
} else if (key == "clipped") {
|
||||
if (!parseBool(s.clipped)) return false;
|
||||
if (!r.parseBool(s.clipped)) return false;
|
||||
} else if (key == "tier") {
|
||||
int v = 0;
|
||||
if (!parseInt(v)) return false;
|
||||
if (!r.parseInt(v)) return false;
|
||||
// Valid range: Scratch(0) .. Archive(1).
|
||||
if (v < static_cast<int>(Tier::Scratch) ||
|
||||
v > static_cast<int>(Tier::Archive))
|
||||
return false;
|
||||
s.tier = static_cast<Tier>(v);
|
||||
} else if (key == "contentHash") {
|
||||
if (!parseString(s.contentHash)) return false;
|
||||
if (!r.parseString(s.contentHash)) return false;
|
||||
} else if (key == "provenance") {
|
||||
bool wasNull = false;
|
||||
if (!expectNullOr(wasNull)) return false;
|
||||
if (!r.expectNullOr(wasNull)) return false;
|
||||
if (wasNull) {
|
||||
s.provenance.reset();
|
||||
} else {
|
||||
if (!consume('{')) return false;
|
||||
if (!r.consume('{')) return false;
|
||||
Provenance p;
|
||||
do {
|
||||
std::string pk;
|
||||
if (!parseKey(pk)) return false;
|
||||
if (!r.parseKey(pk)) return false;
|
||||
std::string pv;
|
||||
if (!parseString(pv)) return false;
|
||||
if (!r.parseString(pv)) return false;
|
||||
if (pk == "parentSampleId") p.parentSampleId = pv;
|
||||
else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
s.provenance = p;
|
||||
}
|
||||
} else if (key == "createdTimestamp") {
|
||||
if (!parseInt64(s.createdTimestamp)) return false;
|
||||
if (!r.parseInt64(s.createdTimestamp)) return false;
|
||||
} else {
|
||||
if (!skipValue()) return false; // forward-compat: ignore unknown
|
||||
if (!r.skipValue()) return false; // forward-compat: ignore unknown
|
||||
}
|
||||
} while (consume(','));
|
||||
} while (r.consume(','));
|
||||
|
||||
return consume('}');
|
||||
return r.consume('}');
|
||||
}
|
||||
|
||||
bool Parser::parseIndex(BankIndex& out) {
|
||||
if (!consume('{')) return false;
|
||||
skipWs();
|
||||
if (consume('}')) return true; // empty object — vacuously an empty index
|
||||
bool parseIndex(json::Reader& r, BankIndex& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true; // empty object — vacuously an empty index
|
||||
|
||||
std::vector<Sample> parsed;
|
||||
do {
|
||||
std::string key;
|
||||
if (!parseKey(key)) return false;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "samples") {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (!consume(']')) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (!r.consume(']')) {
|
||||
do {
|
||||
Sample s;
|
||||
if (!parseSample(s)) return false;
|
||||
if (!parseSample(r, s)) return false;
|
||||
parsed.push_back(std::move(s));
|
||||
} while (consume(','));
|
||||
if (!consume(']')) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume(']')) return false;
|
||||
}
|
||||
} else {
|
||||
if (!skipValue()) return false; // version, or unknown keys
|
||||
if (!r.skipValue()) return false; // version, or unknown keys
|
||||
}
|
||||
} while (consume(','));
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!consume('}')) return false;
|
||||
if (!r.consume('}')) return false;
|
||||
|
||||
// Trailing garbage after the root object is malformed.
|
||||
skipWs();
|
||||
if (!eof()) return false;
|
||||
r.skipWs();
|
||||
if (!r.eof()) return false;
|
||||
|
||||
// Rebuild via add() so the same invariants (relative-path, dedup) that guard
|
||||
// live inserts also guard deserialized data. Rejected/collapsed entries are
|
||||
@@ -757,10 +451,10 @@ bool Parser::parseIndex(BankIndex& out) {
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<BankIndex> BankIndex::deserialize(const std::string& json) {
|
||||
std::optional<BankIndex> BankIndex::deserialize(const std::string& blob) {
|
||||
BankIndex idx;
|
||||
Parser p(json);
|
||||
if (!p.parseIndex(idx)) return std::nullopt;
|
||||
json::Reader r(blob);
|
||||
if (!parseIndex(r, idx)) return std::nullopt;
|
||||
return idx;
|
||||
}
|
||||
|
||||
|
||||
+4
-14
@@ -43,6 +43,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "capture_paths.h"
|
||||
#include "core/util/file_bytes.h"
|
||||
#include "render_settings.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
@@ -226,20 +227,9 @@ std::string makeUniqueTag() {
|
||||
return std::to_string(static_cast<long long>(now));
|
||||
}
|
||||
|
||||
// Reads the whole file into a byte buffer. Returns an empty vector on any I/O
|
||||
// failure (the caller then leaves contentHash empty — the safe, confirm-eliciting
|
||||
// direction for an unreadable file).
|
||||
std::vector<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;
|
||||
}
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
|
||||
// empty on any I/O failure (the caller then leaves contentHash empty — the safe,
|
||||
// confirm-eliciting direction for an unreadable file).
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "capture_paths.h" // hashBytes, deriveBankPaths
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "peaks.h" // lastFrameAboveThreshold, AudioSample
|
||||
#include "realtime_record.h"
|
||||
#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
|
||||
@@ -323,20 +324,9 @@ private:
|
||||
|
||||
namespace {
|
||||
|
||||
// Reads the whole file into a byte buffer. Empty vector on any I/O failure — the
|
||||
// caller treats an unreadable file as "skip the trim" (keep the untrimmed window),
|
||||
// never as a corruption of the recorded audio.
|
||||
std::vector<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;
|
||||
}
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
|
||||
// empty on any I/O failure — the caller treats an unreadable file as "skip the
|
||||
// trim" (keep the untrimmed window), never as a corruption of the recorded audio.
|
||||
|
||||
// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields).
|
||||
void writeU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
|
||||
@@ -371,7 +361,7 @@ double trimAutoTailInPlace(const std::string& path,
|
||||
double rangeEndSeconds) {
|
||||
constexpr double kNoTrim = -1.0;
|
||||
|
||||
std::vector<std::uint8_t> bytes = readAllBytes(path);
|
||||
std::vector<std::uint8_t> bytes = readFileBytes(path);
|
||||
if (bytes.empty()) return kNoTrim;
|
||||
|
||||
const reasampler::WavLayout layout = parseWavLayout(bytes);
|
||||
@@ -533,7 +523,7 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
||||
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats
|
||||
// "" as non-participating).
|
||||
{
|
||||
const std::vector<std::uint8_t> fileBytes = readAllBytes(destPath);
|
||||
const std::vector<std::uint8_t> fileBytes = readFileBytes(destPath);
|
||||
if (!fileBytes.empty()) {
|
||||
result.sample.contentHash = hashWavContent(fileBytes);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -22,6 +22,7 @@
|
||||
#include "bank_model.h" // Sample, AddResult, findByHash
|
||||
#include "bank_panel.h" // bankPanelRefresh
|
||||
#include "capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId)
|
||||
#include "instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block)
|
||||
#include "persist.h" // ReaSamplerSession
|
||||
@@ -81,19 +82,8 @@ std::string currentProjectDir() {
|
||||
return projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
// Reads a whole file's bytes. Empty vector on any failure (missing / unreadable). Mirror
|
||||
// of capture.cpp's readFileBytes — used to read the source and validate/hash the bank copy.
|
||||
std::vector<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;
|
||||
}
|
||||
// Whole-file reads (source read + bank-copy validate/hash) go through the shared
|
||||
// core/util readFileBytes (Q-W1, T2-03): empty on any failure (missing / unreadable).
|
||||
|
||||
// Writes a byte buffer to a file. Returns true on success. The caller is responsible for
|
||||
// ensuring the directory exists before calling.
|
||||
|
||||
+23
-225
@@ -1,19 +1,17 @@
|
||||
#include "owned_manifest.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// owned_manifest implementation.
|
||||
//
|
||||
// JSON is hand-rolled and self-contained (project convention: the pure core is
|
||||
// dependency-free — no third-party JSON lib, mirror of bank_model / bank_book /
|
||||
// tail_control). The shape is a single object with one string array:
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model /
|
||||
// bank_book / tail_control). The shape is a single object with one string array:
|
||||
//
|
||||
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
|
||||
//
|
||||
// so a compact writer + a focused string-array parser is all it needs — far smaller
|
||||
// than bank_model's full recursive-descent parser, because there is exactly one key
|
||||
// and one value kind.
|
||||
// so a compact writer + a focused string-array domain parse is all it needs.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -56,237 +54,37 @@ bool OwnedFileManifest::contains(const std::string& relativePath) const {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON writer
|
||||
// JSON writer (shared core/json escape — byte-identical to the prior local one)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
void writeEscaped(std::string& out, const std::string& s) {
|
||||
out += '"';
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<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 out = "{\"owned\":[";
|
||||
for (std::size_t i = 0; i < paths_.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
writeEscaped(out, paths_[i]);
|
||||
json::writeEscaped(out, paths_[i]);
|
||||
}
|
||||
out += "]}";
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON parser (string-array only)
|
||||
// JSON parser (string-array-only DOMAIN grammar over the shared core/json
|
||||
// lexical layer). Tolerates unknown keys (forward-compat) and requires the
|
||||
// "owned" value to be an array of strings.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const std::string& s) : s_(s) {}
|
||||
|
||||
// Parse the manifest object into `out`. Tolerates unknown keys (forward-compat)
|
||||
// and requires the "owned" value to be an array of strings.
|
||||
bool parseManifest(OwnedFileManifest& out);
|
||||
|
||||
private:
|
||||
const std::string& s_;
|
||||
std::size_t pos_ = 0;
|
||||
|
||||
bool eof() const { return pos_ >= s_.size(); }
|
||||
|
||||
void skipWs() {
|
||||
while (!eof()) {
|
||||
char c = s_[pos_];
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
bool consume(char c) {
|
||||
skipWs();
|
||||
if (eof() || s_[pos_] != c) return false;
|
||||
++pos_;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseString(std::string& out);
|
||||
bool parseStringArray(std::vector<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
|
||||
bool parseManifest(json::Reader& r, OwnedFileManifest& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true; // empty object -> empty manifest
|
||||
for (;;) {
|
||||
std::string key;
|
||||
if (!parseString(key)) return false;
|
||||
if (!consume(':')) return false;
|
||||
if (!r.parseKey(key)) return false;
|
||||
if (key == "owned") {
|
||||
std::vector<std::string> paths;
|
||||
if (!parseStringArray(paths)) return false;
|
||||
if (!r.parseStringArray(paths)) return false;
|
||||
for (auto& p : paths) {
|
||||
// Feed through add() so the persisted invariants (dedup, reject
|
||||
// empty/absolute) are re-asserted on load — a hand-edited or corrupt
|
||||
@@ -294,21 +92,21 @@ bool Parser::parseManifest(OwnedFileManifest& out) {
|
||||
out.add(p);
|
||||
}
|
||||
} else {
|
||||
if (!skipValue()) return false; // forward-compat: tolerate unknown keys
|
||||
if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys
|
||||
}
|
||||
skipWs();
|
||||
if (consume(',')) continue;
|
||||
if (consume('}')) return true;
|
||||
r.skipWs();
|
||||
if (r.consume(',')) continue;
|
||||
if (r.consume('}')) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& json) {
|
||||
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& blob) {
|
||||
OwnedFileManifest m;
|
||||
Parser p(json);
|
||||
if (!p.parseManifest(m)) return std::nullopt;
|
||||
json::Reader r(blob);
|
||||
if (!parseManifest(r, m)) return std::nullopt;
|
||||
return m;
|
||||
}
|
||||
|
||||
|
||||
+8
-113
@@ -1,8 +1,8 @@
|
||||
#include "provenance.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <limits>
|
||||
|
||||
#include "core/wire/wire.h"
|
||||
|
||||
// provenance implementation — pure, self-contained (no third-party lib, mirror of
|
||||
// bank_model's hand-rolled encoding discipline).
|
||||
@@ -39,12 +39,12 @@ namespace {
|
||||
|
||||
constexpr const char* kMagic = "rsprov1";
|
||||
|
||||
// Append one length-prefixed field: <decimal-len> ':' <bytes>
|
||||
void putField(std::string& out, const std::string& field) {
|
||||
out += std::to_string(field.size());
|
||||
out += ':';
|
||||
out += field;
|
||||
}
|
||||
// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full
|
||||
// hardening (incl. the fixed fieldInt range check that closes the old strtol
|
||||
// silent-narrowing TODO). Only the %.17g double rendering stays local — it is
|
||||
// this writer's convention, shared with the bank model's JSON doubles.
|
||||
using wire::putField;
|
||||
using Cursor = wire::Cursor;
|
||||
|
||||
std::string dblToStr(double v) {
|
||||
char buf[32];
|
||||
@@ -52,111 +52,6 @@ std::string dblToStr(double v) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Cursor over the encoded string. All reads are bounds-checked; any short read
|
||||
// fails the whole parse (ok_ latches false).
|
||||
class Cursor {
|
||||
public:
|
||||
explicit Cursor(const std::string& s) : s_(s) {}
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
bool atEnd() const { return pos_ >= s_.size(); }
|
||||
|
||||
// Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or
|
||||
// non-numeric length, a length that overflows SIZE_MAX, or a length that runs past
|
||||
// the end. Hardened form backported from the assignment_request / sample_usage
|
||||
// siblings (Q-W0 T2-01a): the digit count is capped at 20 (the decimal width of
|
||||
// SIZE_MAX on a 64-bit host) so a crafted 200-digit length cannot accumulate past
|
||||
// SIZE_MAX via repeated multiply, and the bounds check is subtraction-first so a
|
||||
// huge `len` cannot wrap `start + len` past the end test.
|
||||
bool field(std::string& out) {
|
||||
if (!ok_) return false;
|
||||
const std::size_t colon = s_.find(':', pos_);
|
||||
if (colon == std::string::npos) return fail();
|
||||
if (colon == pos_) return fail(); // empty length token
|
||||
// Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus.
|
||||
if (colon - pos_ > 20u) return fail();
|
||||
std::size_t len = 0;
|
||||
for (std::size_t i = pos_; i < colon; ++i) {
|
||||
const char c = s_[i];
|
||||
if (c < '0' || c > '9') return fail();
|
||||
const std::size_t digit = static_cast<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
|
||||
|
||||
std::string fxChainIdentity(const std::vector<FxIdentityEntry>& entries) {
|
||||
|
||||
+10
-78
@@ -3,8 +3,8 @@
|
||||
#include "sample_usage.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
|
||||
#include "core/wire/wire.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -12,81 +12,13 @@ namespace {
|
||||
|
||||
constexpr const char* kMagic = "rsusage1";
|
||||
|
||||
// Append one length-prefixed field: <decimal-len> ':' <bytes>. The same wire idiom as
|
||||
// assignment_request / provenance — one grammar across every ext-state seam.
|
||||
void putField(std::string& out, const std::string& field) {
|
||||
out += std::to_string(field.size());
|
||||
out += ':';
|
||||
out += field;
|
||||
}
|
||||
|
||||
// Bounds-checked cursor over the encoded string (the assignment_request Cursor, trimmed
|
||||
// to the two field kinds this record needs). A short read latches ok_ false.
|
||||
class Cursor {
|
||||
public:
|
||||
explicit Cursor(const std::string& s) : s_(s) {}
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
bool atEnd() const { return pos_ >= s_.size(); }
|
||||
|
||||
bool field(std::string& out) {
|
||||
if (!ok_) return false;
|
||||
const std::size_t colon = s_.find(':', pos_);
|
||||
if (colon == std::string::npos) return fail();
|
||||
if (colon == pos_) return fail(); // empty length token
|
||||
if (colon - pos_ > 20u) return fail(); // SIZE_MAX is 20 decimal digits
|
||||
std::size_t len = 0;
|
||||
for (std::size_t i = pos_; i < colon; ++i) {
|
||||
const char c = s_[i];
|
||||
if (c < '0' || c > '9') return fail();
|
||||
const std::size_t digit = static_cast<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;
|
||||
};
|
||||
// The shared core/wire codec (Q-W1, T2-01b) — one grammar across every
|
||||
// ext-state seam. The former local fieldCount (10-digit cap) is subsumed by the
|
||||
// codec's fieldSizeT (20-digit cap + overflow-guarded accumulate): every count
|
||||
// the old cap accepted decodes identically, and any larger count is rejected by
|
||||
// the count-vs-wire-size sanity bound at the call site below.
|
||||
using wire::putField;
|
||||
using Cursor = wire::Cursor;
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -115,7 +47,7 @@ std::optional<UsageRecord> decodeUsageRecord(const std::string& wire) {
|
||||
else if (unionedField == "0") rec.unioned = false;
|
||||
else return std::nullopt; // anything else is corruption -> reject whole
|
||||
std::size_t count = 0;
|
||||
if (!c.fieldCount(count)) return std::nullopt;
|
||||
if (!c.fieldSizeT(count)) return std::nullopt;
|
||||
// Each hold needs at least 4 wire bytes ("0:0:"), so a count past wire.size()/4 is
|
||||
// provably bogus — reject before looping rather than iterating a crafted huge count.
|
||||
if (count > wire.size() / 4u + 1u) return std::nullopt;
|
||||
|
||||
+43
-44
@@ -3,10 +3,9 @@
|
||||
#include "tail_control.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -51,13 +50,13 @@ std::string tailToggleLabel(const TailSetting& setting) {
|
||||
// JSON round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The setting is a flat object of one enum + one double, so a compact hand-rolled
|
||||
// writer + a tolerant minimal reader is the simplest thing that works (mirroring
|
||||
// bank_model's dependency-free JSON choice). manualMs is emitted with 17 significant
|
||||
// digits (%.17g) — the shortest form that round-trips every IEEE-754 double exactly —
|
||||
// so deserialize(serialize(x)) == x holds bit-for-bit. deserialize is deliberately
|
||||
// forgiving: any parse failure returns nullopt so the caller falls back to a default,
|
||||
// exactly as an absent ext-state key does.
|
||||
// The setting is a flat object of one enum + one double, riding the shared
|
||||
// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader —
|
||||
// the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17
|
||||
// significant digits (%.17g) — the shortest form that round-trips every IEEE-754
|
||||
// double exactly — so deserialize(serialize(x)) == x holds bit-for-bit.
|
||||
// deserialize stays forgiving in outcome: any parse failure returns nullopt so
|
||||
// the caller falls back to a default, exactly as an absent ext-state key does.
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -81,48 +80,48 @@ std::optional<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
|
||||
|
||||
std::string serializeTailSetting(const TailSetting& setting) {
|
||||
char buf[128];
|
||||
std::snprintf(buf, sizeof(buf), "{\"mode\":%d,\"manualMs\":%.17g}",
|
||||
modeToInt(setting.mode), setting.manualMs);
|
||||
return std::string(buf);
|
||||
// Byte-identical to the former snprintf writer: {"mode":%d,"manualMs":%.17g}.
|
||||
std::string out;
|
||||
{
|
||||
json::Writer w(out);
|
||||
w.keyRaw("mode", json::numToStr(modeToInt(setting.mode)));
|
||||
w.keyRaw("manualMs", json::numToStr(setting.manualMs));
|
||||
} // Writer closes the object here (see bank_model's NRVO note)
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<TailSetting> deserializeTailSetting(const std::string& json) {
|
||||
const char* modeTok = valueAfterKey(json, "mode");
|
||||
const char* msTok = valueAfterKey(json, "manualMs");
|
||||
if (!modeTok || !msTok) return std::nullopt; // absent key -> malformed -> default
|
||||
std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
|
||||
json::Reader r(blob);
|
||||
if (!r.consume('{')) return std::nullopt;
|
||||
|
||||
char* end = nullptr;
|
||||
errno = 0;
|
||||
const long modeVal = std::strtol(modeTok, &end, 10);
|
||||
if (end == modeTok || errno != 0) return std::nullopt;
|
||||
const std::optional<TailMode> mode = modeFromInt(static_cast<int>(modeVal));
|
||||
int modeInt = 0;
|
||||
double ms = 0.0;
|
||||
bool haveMode = false, haveMs = false;
|
||||
r.skipWs();
|
||||
if (!r.consume('}')) {
|
||||
do {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return std::nullopt;
|
||||
if (key == "mode") {
|
||||
if (!r.parseInt(modeInt)) return std::nullopt;
|
||||
haveMode = true;
|
||||
} else if (key == "manualMs") {
|
||||
if (!r.parseDouble(ms)) return std::nullopt;
|
||||
haveMs = true;
|
||||
} else {
|
||||
if (!r.skipValue()) return std::nullopt; // forward-compat
|
||||
}
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return std::nullopt;
|
||||
}
|
||||
if (!haveMode || !haveMs) return std::nullopt; // absent key -> malformed -> default
|
||||
|
||||
const std::optional<TailMode> mode = modeFromInt(modeInt);
|
||||
if (!mode) return std::nullopt;
|
||||
|
||||
end = nullptr;
|
||||
errno = 0;
|
||||
const double ms = std::strtod(msTok, &end);
|
||||
if (end == msTok || errno != 0) return std::nullopt;
|
||||
|
||||
TailSetting out;
|
||||
out.mode = *mode;
|
||||
out.manualMs = ms;
|
||||
|
||||
+93
-343
@@ -2,19 +2,16 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cerrno>
|
||||
#include <climits>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <set>
|
||||
#include <utility>
|
||||
|
||||
#include "core/json/json.h"
|
||||
#include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention
|
||||
|
||||
// view_mode_model implementation.
|
||||
//
|
||||
// JSON is hand-rolled and self-contained, mirroring bank_model's approach (brief:
|
||||
// keep the pure core dependency-free — no third-party JSON lib). A compact writer
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1), mirroring bank_model.
|
||||
// A compact writer
|
||||
// plus a recursive-descent parser covers the field set: the mode registry, the
|
||||
// GUID-keyed membership map, per-track snapshots (with a variable-length per-FX
|
||||
// offline vector), and the active mode. Ints are emitted plainly; strings are
|
||||
@@ -493,73 +490,12 @@ bool ViewModeModel::operator==(const ViewModeModel& o) const {
|
||||
|
||||
namespace {
|
||||
|
||||
void writeEscaped(std::string& out, const std::string& s) {
|
||||
out += '"';
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<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;
|
||||
};
|
||||
// Shared core/json emit helpers (Q-W1): same escape set + %d rendering as the
|
||||
// prior file-local writer, so the emitted blob is byte-identical.
|
||||
using json::writeEscaped;
|
||||
using json::writeIntArray;
|
||||
std::string intToStr(int v) { return json::numToStr(v); }
|
||||
using ObjWriter = json::Writer;
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -660,250 +596,64 @@ std::string ViewModeModel::serialize() const {
|
||||
|
||||
namespace {
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const std::string& s) : s_(s) {}
|
||||
bool parseModel(ViewModeModel& out);
|
||||
|
||||
private:
|
||||
const std::string& s_;
|
||||
std::size_t pos_ = 0;
|
||||
|
||||
bool eof() const { return pos_ >= s_.size(); }
|
||||
|
||||
void skipWs() {
|
||||
while (!eof()) {
|
||||
char c = s_[pos_];
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
bool consume(char c) {
|
||||
skipWs();
|
||||
if (eof() || s_[pos_] != c) return false;
|
||||
++pos_;
|
||||
return true;
|
||||
}
|
||||
bool parseString(std::string& out);
|
||||
bool parseRawScalar(std::string& out);
|
||||
bool parseInt(int& out);
|
||||
bool parseBool(bool& out);
|
||||
bool parseKey(std::string& key);
|
||||
bool skipValue();
|
||||
|
||||
bool parseModes(ModeRegistry& reg);
|
||||
bool parseMembership(MembershipIndex& idx);
|
||||
bool parseSnapshots(std::map<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 model DOMAIN grammar over the shared core/json lexical layer (Q-W1).
|
||||
|
||||
// The registry starts seeded (Arrange + Design). Deserialization must reproduce the
|
||||
// serialized set exactly, so we replace the seeded contents with the parsed ones —
|
||||
// add() dedups by id, so a serialized Arrange/Design would otherwise be rejected as
|
||||
// duplicates and the ordinals/names would not round-trip. We therefore parse into a
|
||||
// fresh vector and swap. `reg` is passed empty (see parseModel).
|
||||
bool Parser::parseModes(ModeRegistry& reg) {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true; // empty array (unusual, but valid)
|
||||
bool parseModes(json::Reader& r, ModeRegistry& reg) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true; // empty array (unusual, but valid)
|
||||
do {
|
||||
if (!consume('{')) return false;
|
||||
if (!r.consume('{')) return false;
|
||||
Mode m;
|
||||
bool haveId = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!parseKey(k)) return false;
|
||||
if (k == "id") { if (!parseString(m.id)) return false; haveId = true; }
|
||||
else if (k == "displayName") { if (!parseString(m.displayName)) return false; }
|
||||
else if (k == "ordinal") { if (!parseInt(m.ordinal)) return false; }
|
||||
else if (!skipValue()) return false;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "id") { if (!r.parseString(m.id)) return false; haveId = true; }
|
||||
else if (k == "displayName") { if (!r.parseString(m.displayName)) return false; }
|
||||
else if (k == "ordinal") { if (!r.parseInt(m.ordinal)) return false; }
|
||||
else if (!r.skipValue()) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveId || !reg.add(m)) return false; // malformed / duplicate id
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseMembership(MembershipIndex& idx) {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true;
|
||||
bool parseMembership(json::Reader& r, MembershipIndex& idx) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true;
|
||||
do {
|
||||
if (!consume('{')) return false;
|
||||
if (!r.consume('{')) return false;
|
||||
std::string guid;
|
||||
Membership mem;
|
||||
bool haveGuid = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!parseKey(k)) return false;
|
||||
if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; }
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
|
||||
else if (k == "modes") {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (!consume(']')) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (!r.consume(']')) {
|
||||
do {
|
||||
std::string id;
|
||||
if (!parseString(id)) return false;
|
||||
if (!r.parseString(id)) return false;
|
||||
mem.modeIds.insert(id);
|
||||
} while (consume(','));
|
||||
if (!consume(']')) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume(']')) return false;
|
||||
}
|
||||
}
|
||||
else if (k == "showBoth") { if (!parseBool(mem.showBoth)) return false; }
|
||||
else if (!skipValue()) return false;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
else if (k == "showBoth") { if (!r.parseBool(mem.showBoth)) return false; }
|
||||
else if (!r.skipValue()) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveGuid || guid.empty()) return false;
|
||||
// Install the entry verbatim (tag() would clear a multi-mode set and drop
|
||||
// show-both). A serialized entry is trusted to already satisfy the model's
|
||||
@@ -918,55 +668,55 @@ bool Parser::parseMembership(MembershipIndex& idx) {
|
||||
// mode that no longer exists has an immediate behavioral consequence, so it
|
||||
// is caught and the parse is rejected.
|
||||
if (!idx.restore(guid, mem)) return false;
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseSnapshots(std::map<std::string, TrackSnapshot>& snaps) {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true;
|
||||
bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true;
|
||||
do {
|
||||
if (!consume('{')) return false;
|
||||
if (!r.consume('{')) return false;
|
||||
std::string guid;
|
||||
TrackSnapshot snap;
|
||||
bool haveGuid = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!parseKey(k)) return false;
|
||||
if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; }
|
||||
else if (k == "showInTcp") { if (!parseInt(snap.showInTcp)) return false; }
|
||||
else if (k == "showInMixer") { if (!parseInt(snap.showInMixer)) return false; }
|
||||
else if (k == "mainSend") { if (!parseInt(snap.mainSend)) return false; }
|
||||
else if (k == "fxEnable") { if (!parseInt(snap.fxEnable)) return false; }
|
||||
else if (k == "fxOffline") { if (!parseIntArray(snap.fxOffline)) return false; }
|
||||
else if (!skipValue()) return false;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
|
||||
else if (k == "showInTcp") { if (!r.parseInt(snap.showInTcp)) return false; }
|
||||
else if (k == "showInMixer") { if (!r.parseInt(snap.showInMixer)) return false; }
|
||||
else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; }
|
||||
else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; }
|
||||
else if (k == "fxOffline") { if (!r.parseIntArray(snap.fxOffline)) return false; }
|
||||
else if (!r.skipValue()) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveGuid || guid.empty()) return false;
|
||||
snaps[guid] = snap;
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseLanes(LaneOwnershipIndex& idx) {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true;
|
||||
bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true;
|
||||
do {
|
||||
if (!consume('{')) return false;
|
||||
if (!r.consume('{')) return false;
|
||||
std::string trackGuid, laneKey, mode;
|
||||
bool haveTrack = false, haveLane = false, managed = false, haveManaged = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!parseKey(k)) return false;
|
||||
if (k == "trackGuid") { if (!parseString(trackGuid)) return false; haveTrack = true; }
|
||||
else if (k == "laneKey") { if (!parseString(laneKey)) return false; haveLane = true; }
|
||||
else if (k == "managed") { if (!parseBool(managed)) return false; haveManaged = true; }
|
||||
else if (k == "mode") { if (!parseString(mode)) return false; }
|
||||
else if (!skipValue()) return false;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "trackGuid") { if (!r.parseString(trackGuid)) return false; haveTrack = true; }
|
||||
else if (k == "laneKey") { if (!r.parseString(laneKey)) return false; haveLane = true; }
|
||||
else if (k == "managed") { if (!r.parseBool(managed)) return false; haveManaged = true; }
|
||||
else if (k == "mode") { if (!r.parseString(mode)) return false; }
|
||||
else if (!r.skipValue()) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
// Both keys mandatory and non-empty (they form the lane's identity). A managed
|
||||
// lane must carry a non-empty mode; a manual lane must not claim one. Enforcing
|
||||
// this on parse keeps a round-tripped index byte-for-byte identical to the
|
||||
@@ -980,14 +730,14 @@ bool Parser::parseLanes(LaneOwnershipIndex& idx) {
|
||||
if (!mode.empty()) return false; // manual lane must not carry a mode
|
||||
if (!idx.setManual(trackGuid, laneKey)) return false;
|
||||
}
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseModel(ViewModeModel& out) {
|
||||
if (!consume('{')) return false;
|
||||
skipWs();
|
||||
if (consume('}')) return true; // lenient empty root ⇒ default-seeded model
|
||||
bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true; // lenient empty root ⇒ default-seeded model
|
||||
|
||||
ModeRegistry reg; // seeded default; REPLACED if a modes array is present
|
||||
bool haveModes = false;
|
||||
@@ -999,33 +749,33 @@ bool Parser::parseModel(ViewModeModel& out) {
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
if (!parseKey(key)) return false;
|
||||
if (!r.parseKey(key)) return false;
|
||||
if (key == "activeMode") {
|
||||
if (!parseString(activeMode)) return false;
|
||||
if (!r.parseString(activeMode)) return false;
|
||||
haveActive = true;
|
||||
} else if (key == "modes") {
|
||||
ModeRegistry fresh = ModeRegistry::makeEmpty(); // parse into empty, then own
|
||||
if (!parseModes(fresh)) return false;
|
||||
if (!parseModes(r, fresh)) return false;
|
||||
reg = fresh;
|
||||
haveModes = true;
|
||||
} else if (key == "membership") {
|
||||
if (!parseMembership(membership)) return false;
|
||||
if (!parseMembership(r, membership)) return false;
|
||||
} else if (key == "snapshots") {
|
||||
if (!parseSnapshots(snaps)) return false;
|
||||
if (!parseSnapshots(r, snaps)) return false;
|
||||
} else if (key == "lanes") {
|
||||
if (!parseLanes(lanes)) return false;
|
||||
if (!parseLanes(r, lanes)) return false;
|
||||
} else {
|
||||
// Unknown keys and the "version" field are skipped here.
|
||||
// "version" is serialized as a forward-compat placeholder — there is no
|
||||
// active version gate yet; all persisted data is parsed the same way
|
||||
// regardless of the value. A future gate would add a version branch here.
|
||||
if (!skipValue()) return false;
|
||||
if (!r.skipValue()) return false;
|
||||
}
|
||||
} while (consume(','));
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!consume('}')) return false;
|
||||
skipWs();
|
||||
if (!eof()) return false; // trailing garbage
|
||||
if (!r.consume('}')) return false;
|
||||
r.skipWs();
|
||||
if (!r.eof()) return false; // trailing garbage
|
||||
|
||||
if (haveModes) out.modes() = reg;
|
||||
out.membership() = membership;
|
||||
@@ -1039,10 +789,10 @@ bool Parser::parseModel(ViewModeModel& out) {
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string& json) {
|
||||
std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string& blob) {
|
||||
ViewModeModel vm;
|
||||
Parser p(json);
|
||||
if (!p.parseModel(vm)) return std::nullopt;
|
||||
json::Reader r(blob);
|
||||
if (!parseModel(r, vm)) return std::nullopt;
|
||||
return vm;
|
||||
}
|
||||
|
||||
|
||||
+7
-14
@@ -3,27 +3,20 @@
|
||||
#include "bank_sync.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
#include "core/wire/wire.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
std::int64_t parseBankGeneration(const std::string& raw) {
|
||||
if (raw.empty()) return kBankGenerationAbsent;
|
||||
|
||||
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale surprises.
|
||||
// A leading '+' / '-' , any non-digit, an empty digit run, or overflow past int64 max
|
||||
// all reject to the absent default (0). Manual accumulation with an overflow guard so a
|
||||
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale
|
||||
// surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading
|
||||
// '+' / '-', any non-digit, an empty string, or overflow past int64 max all
|
||||
// reject to the absent default (0); the guarded accumulate means a
|
||||
// pathologically long digit run can never wrap into a bogus small value.
|
||||
std::int64_t value = 0;
|
||||
constexpr std::int64_t kMax = std::numeric_limits<std::int64_t>::max();
|
||||
for (const char c : raw) {
|
||||
if (c < '0' || c > '9') return kBankGenerationAbsent; // any non-digit -> reject whole
|
||||
const int digit = c - '0';
|
||||
// Guard value*10 + digit against overflow before performing it.
|
||||
if (value > (kMax - digit) / 10) return kBankGenerationAbsent; // would overflow -> reject
|
||||
value = value * 10 + digit;
|
||||
}
|
||||
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "capture_browser.h"
|
||||
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "curve_popup.h" // r11 centered curve-popup sheet geometry (FB1)
|
||||
#include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3)
|
||||
#include "editor_geometry.h" // Rect, contains
|
||||
@@ -770,16 +771,8 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
if (!relativePath.empty()) {
|
||||
const std::string projectDir = processor_->bridge().activeProjectDir();
|
||||
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::ifstream f(abs, std::ios::binary | std::ios::ate);
|
||||
if (f) {
|
||||
const std::streamoff size = f.tellg();
|
||||
if (size > 0) {
|
||||
f.seekg(0, std::ios::beg);
|
||||
bytes.resize(static_cast<std::size_t>(size));
|
||||
if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
|
||||
}
|
||||
}
|
||||
// Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure.
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (layout.valid) {
|
||||
std::vector<AudioSample> interleaved =
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse)
|
||||
#include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
|
||||
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract)
|
||||
#include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
|
||||
#include "reasampler_editor.h"
|
||||
@@ -69,19 +70,9 @@ std::string mintUsageInstanceGuid() {
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
|
||||
// any failure — the caller treats an unreadable WAV as "nothing to play".
|
||||
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
||||
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;
|
||||
}
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03).
|
||||
// Off-thread only (blocking file I/O). Empty on any failure — the caller treats
|
||||
// an unreadable WAV as "nothing to play".
|
||||
|
||||
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
|
||||
// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -135,6 +135,20 @@ static void testRoundTripAuto() {
|
||||
CHECK(back && settingsEqual(*back, s));
|
||||
}
|
||||
|
||||
static void testSerializeByteIdentity() {
|
||||
// Q-W1 structural-dedupe guard: the core/json-backed writer must emit the
|
||||
// EXACT bytes the former snprintf writer produced ({"mode":%d,"manualMs":%.17g})
|
||||
// and re-serializing a round-tripped setting must be byte-identical — the blob
|
||||
// lives in the .rpp, so a byte shift would dirty every saved project.
|
||||
TailSetting s; // None + 2000.0 default
|
||||
CHECK(serializeTailSetting(s) == "{\"mode\":0,\"manualMs\":2000}");
|
||||
TailSetting man; man.mode = TailMode::Manual; man.manualMs = 3141.592653589793;
|
||||
const std::string json = serializeTailSetting(man);
|
||||
auto back = deserializeTailSetting(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && serializeTailSetting(*back) == json); // stable second round-trip
|
||||
}
|
||||
|
||||
static void testDeserializeEmptyIsDefault() {
|
||||
// An absent/empty stored value (older project) -> nullopt, so the caller falls
|
||||
// back to the default. This is the graceful-old-project path the brief requires.
|
||||
@@ -164,6 +178,7 @@ int main() {
|
||||
testRoundTripNoneDefault();
|
||||
testRoundTripManualArbitraryMs();
|
||||
testRoundTripAuto();
|
||||
testSerializeByteIdentity();
|
||||
testDeserializeEmptyIsDefault();
|
||||
testDeserializeMalformedIsDefault();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user