/* text_xlate.cpp — on-the-fly string translation / localization (framework). * See text_xlate.h + docs/STRING_TRANSLATION.md. */ #include "text_xlate.h" #include "cpu_state.h" #include #include #include #include #include #include #include #include #include #include #include #include "toml.hpp" extern "C" uint8_t* memory_get_ram_ptr(void); extern "C" { extern uint64_t s_frame_count; } /* Renderer VRAM facade (gpu_render.h) — the same read/write path the GP0 0xA0 * upload uses, so vram-strip patches stay coherent across software/GL backends * and the supersampling mirror. */ extern "C" uint16_t gr_vram_read(int x, int y); extern "C" void gr_vram_write(int x, int y, uint16_t pixel); // Bless an in-place RAM patch into the text-image guard so it isn't mistaken for // self-modifying code (memory.c). Our patches (string/glyph tables) share pages // with real compiled functions; without this the guard blocks their native // dispatch and the game fatally routes to psx_unknown_dispatch. extern "C" void dirty_ram_text_bless(uint32_t phys, const uint8_t* bytes, uint32_t len); namespace { namespace fs = std::filesystem; // --------------------------------------------------------------------------- // Guest RAM access (little-endian, no swizzle). Main RAM is 2 MB, mirrored // across [0,0x800000). Returns 0 / no-op for out-of-range. // --------------------------------------------------------------------------- constexpr uint32_t kRamSize = 2u * 1024u * 1024u; inline bool ram_fold(uint32_t va, uint32_t* pa_out) { uint32_t p = va & 0x1FFFFFFFu; if (p < 0x00800000u) { *pa_out = p & (kRamSize - 1u); return true; } return false; // I/O / BIOS / scratchpad — not translatable text storage } inline uint8_t grb(uint8_t* ram, uint32_t va) { uint32_t pa; return ram_fold(va, &pa) ? ram[pa] : 0u; } inline void gwb(uint8_t* ram, uint32_t va, uint8_t v) { uint32_t pa; if (ram_fold(va, &pa)) ram[pa] = v; } inline bool va_in_ram(uint32_t va) { uint32_t pa; return ram_fold(va, &pa); } // Bless a just-written patch region into the text-image guard (see the extern // above). gwb writes RAM directly; this syncs the guard's reference so the patch // reads as intentional data, not self-modifying code. Reads the now-current bytes // straight from guest RAM, so it always blesses exactly what was written. inline void bless_patch(uint8_t* ram, uint32_t va, uint32_t len) { uint32_t pa; if (len && ram_fold(va, &pa)) dirty_ram_text_bless(va & 0x1FFFFFFFu, ram + pa, len); } // --------------------------------------------------------------------------- // FNV-1a 64 over raw source bytes — the translation KV key. // --------------------------------------------------------------------------- inline uint64_t fnv1a(const uint8_t* p, uint32_t n) { uint64_t h = 1469598103934665603ull; for (uint32_t i = 0; i < n; ++i) { h ^= p[i]; h *= 1099511628211ull; } return h; } // =========================================================================== // EncodingProfile — general text-encoding abstraction (validator + terminator // rules + UTF-8 -> game-glyph transcoder). A title selects one; Tsumu uses // shift_jis. Keeps the capture/apply core encoding-agnostic. // =========================================================================== constexpr uint32_t kSrcMax = 512; // max source record bytes captured/hashed enum class Term { None, Nul, FFFF }; struct EncodingProfile { const char* name; // Cheap first-byte gate: could `c` begin a text record? bool (*first_byte_textish)(uint8_t c); // Read a NUL/terminator-delimited record from guest RAM at va. Fills // out[0..*len) with the raw content bytes (terminator excluded), sets // *term. Returns true only for a plausible text record (>= min real chars). bool (*read_record)(uint8_t* ram, uint32_t va, uint8_t* out, uint32_t* len, Term* term); // Transcode a UTF-8 target string into game glyph bytes appended to `out`. void (*transcode)(const std::string& utf8, std::vector& out); // Authoring quality gate: is this decoded record worth recording in the // always-on capture inventory (vs. binary that passed the reader by chance)? bool (*capture_worthy)(const uint8_t* bytes, uint32_t len); }; // ---- Shift-JIS profile (Tsumu) -------------------------------------------- inline bool sj_lead(uint8_t c) { return (c >= 0x81 && c <= 0x9F) || (c >= 0xE0 && c <= 0xFC); } inline bool sj_trail(uint8_t c) { return (c >= 0x40 && c <= 0x7E) || (c >= 0x80 && c <= 0xFC); } inline bool sj_kana(uint8_t c) { return c >= 0xA1 && c <= 0xDF; } // halfwidth katakana inline bool sj_ascii(uint8_t c) { return c >= 0x20 && c <= 0x7E; } bool sj_first_byte_textish(uint8_t c) { return sj_ascii(c) || sj_kana(c) || sj_lead(c); } // A record ends at NUL (menu/UI labels) or the game's 0xFFFF message terminator // (framed multi-line messages). Content bytes are kept VERBATIM — including the // game's control framing: a lone SJIS-lead byte with no valid trail, 0x8x/0xFE, // and the "FE FF" line-break code — so the hash is byte-exact and the original // renderer sees identical framing. Binary structs are filtered by requiring a // minimum count of decodable 2-byte SJIS units. bool sj_read_record(uint8_t* ram, uint32_t va, uint8_t* out, uint32_t* len, Term* term) { if (!va_in_ram(va)) return false; uint8_t b0 = grb(ram, va); if (!sj_first_byte_textish(b0)) return false; uint32_t n = 0, real = 0, two = 0; // real=text units; two=decodable 2-byte SJIS Term t = Term::None; for (uint32_t i = 0; i < kSrcMax; ++i) { uint8_t c = grb(ram, va + i); if (c == 0x00) { t = Term::Nul; break; } if (c == 0xFF && grb(ram, va + i + 1) == 0xFF) { t = Term::FFFF; break; } if (sj_lead(c) && sj_trail(grb(ram, va + i + 1))) { if (n + 2 > kSrcMax) break; out[n++] = c; out[n++] = grb(ram, va + i + 1); ++i; ++real; ++two; continue; } if (sj_kana(c) || sj_ascii(c) || c == 0x0A) { if (n + 1 > kSrcMax) break; out[n++] = c; if (c != 0x0A) ++real; continue; } // Control / framing byte: lone SJIS lead (0x81..0x9F/0xE0..0xFC with no // valid trail — e.g. the "81 FF" / "FE FF" framing), 0x01..0x1F, 0x80, // 0xFD/0xFE, or a lone 0xFF (second half of a FE-FF line break). Keep it. if (n + 1 > kSrcMax) break; out[n++] = c; } // Reject binary: a genuine string carries at least two decodable 2-byte SJIS // chars (or, for a short pure label, at least three text units). if (t == Term::None) return false; if (two < 2 && real < 3) return false; *len = n; *term = t; return true; } // UTF-8 -> fullwidth-Shift-JIS. Tsumu's font already contains fullwidth Latin + // digits (proven by "TSUMU light"), so English renders with zero glyph // injection. '\n' -> raw 0x0A (the drawer's line break); unknown codepoints -> // fullwidth space. (Halfwidth-ASCII path is a future EncodingProfile variant // once MOJI.BIN confirms a halfwidth Latin block.) uint16_t fw_sjis_for_ascii(uint32_t cp) { if (cp >= 'A' && cp <= 'Z') return (uint16_t)(0x8260 + (cp - 'A')); if (cp >= 'a' && cp <= 'z') return (uint16_t)(0x8281 + (cp - 'a')); if (cp >= '0' && cp <= '9') return (uint16_t)(0x824F + (cp - '0')); switch (cp) { case ' ': return 0x8140; // fullwidth (ideographic) space case '!': return 0x8149; case '?': return 0x8148; case '.': return 0x8144; case ',': return 0x8143; case ':': return 0x8146; case ';': return 0x8147; case '\'': return 0x8166; case '"': return 0x8168; case '(': return 0x8169; case ')': return 0x816A; case '[': return 0x816D; case ']': return 0x816E; case '-': return 0x817C; // fullwidth minus case '/': return 0x815E; case '&': return 0x8195; case '$': return 0x8190; // fullwidth $ (Tsumu font: ○ button glyph) case '%': return 0x8193; // fullwidth % (Tsumu font: ✕ button glyph) case '+': return 0x817B; case '=': return 0x8181; case '*': return 0x8196; case '@': return 0x8197; case '<': return 0x8183; case '>': return 0x8184; case '_': return 0x8151; default: return 0x8140; // unknown -> fullwidth space } } void sj_transcode(const std::string& utf8, std::vector& out) { size_t i = 0; while (i < utf8.size()) { uint8_t c = (uint8_t)utf8[i]; uint32_t cp; size_t adv; if (c < 0x80) { cp = c; adv = 1; } else if ((c & 0xE0) == 0xC0 && i + 1 < utf8.size()) { cp = ((c & 0x1F) << 6) | (utf8[i+1] & 0x3F); adv = 2; } else if ((c & 0xF0) == 0xE0 && i + 2 < utf8.size()) { cp = ((c & 0x0F) << 12) | ((utf8[i+1] & 0x3F) << 6) | (utf8[i+2] & 0x3F); adv = 3; } else { cp = ' '; adv = 1; } i += adv; // Framing tokens the author writes into the English (remapped to the // game's control codes in apply_to_reg): \n line break, \f page break, // \r prompt/wait. Preserve them as their raw ASCII control bytes here. if (cp == '\n') { out.push_back(0x0A); continue; } if (cp == 0x0C) { out.push_back(0x0C); continue; } // \f -> page break if (cp == 0x0D) { out.push_back(0x0D); continue; } // \r -> prompt // A codepoint already in the fullwidth SJIS range would be authored as // raw JP; here targets are Latin. Map ASCII-range codepoints; fullwidth // Unicode letters (U+FF01..) map back to their ASCII then to SJIS. if (cp >= 0xFF01 && cp <= 0xFF5E) cp = cp - 0xFF00 + 0x20; if (cp == 0x3000) cp = ' '; uint16_t g = fw_sjis_for_ascii(cp <= 0x7E ? cp : ' '); // Endianness of the 16-bit glyph code as the game's drawer reads it. // EXE menu labels are big-endian SJIS; the message-table text is stored // little-endian. Selectable per run (PSX_XLATE_LE=1) until confirmed; // once known this becomes an EncodingProfile flag. static const bool le = [] { const char* e = std::getenv("PSX_XLATE_LE"); return e && e[0] == '1'; }(); if (le) { out.push_back((uint8_t)(g & 0xFF)); out.push_back((uint8_t)(g >> 8)); } else { out.push_back((uint8_t)(g >> 8)); out.push_back((uint8_t)(g & 0xFF)); } } } // Transcode a UTF-8 label into fixed-width little-endian fullwidth-Shift-JIS for // an in-place RAM source patch. Each ASCII char maps to its fullwidth glyph // (2 bytes, stored low/high as the game's per-glyph drawer reads it), truncated // to whole cells so at most `width` bytes are produced, then padded with the // fullwidth space (0x8140 -> bytes 40 81) to exactly `width` bytes. NEVER emits // more than `width` bytes — the hard guarantee against corrupting the next slot. void label_transcode_le(const std::string& utf8, uint32_t width, std::vector& out) { out.clear(); const uint32_t maxbytes = width & ~1u; // whole 2-byte cells only size_t i = 0; while (i < utf8.size() && out.size() + 2 <= maxbytes) { uint8_t c = (uint8_t)utf8[i]; uint32_t cp; size_t adv; if (c < 0x80) { cp = c; adv = 1; } else if ((c & 0xE0) == 0xC0 && i + 1 < utf8.size()) { cp = ((c & 0x1F) << 6) | (utf8[i+1] & 0x3F); adv = 2; } else if ((c & 0xF0) == 0xE0 && i + 2 < utf8.size()) { cp = ((c & 0x0F) << 12) | ((utf8[i+1] & 0x3F) << 6) | (utf8[i+2] & 0x3F); adv = 3; } else { cp = ' '; adv = 1; } i += adv; if (cp >= 0xFF01 && cp <= 0xFF5E) cp = cp - 0xFF00 + 0x20; // fullwidth Latin -> ASCII if (cp == 0x3000) cp = ' '; // ideographic space uint16_t g = fw_sjis_for_ascii(cp <= 0x7E ? cp : ' '); out.push_back((uint8_t)(g & 0xFF)); // trail (low) — little-endian storage out.push_back((uint8_t)(g >> 8)); // lead (high) } while (out.size() + 2 <= maxbytes) { out.push_back(0x40); out.push_back(0x81); } // fw-space pad } // Capture-inventory quality gate (authoring signal only — APPLY is KV-gated and // unaffected). Real Tsumu text is hiragana/katakana/fullwidth-alnum/kanji heavy, // whose SJIS lead bytes concentrate in the 0x81/0x82/0x83 and 0x88-0x9F/0xE0-0xEA // rows; vertex/coordinate binary that passes the record reader by chance has // diverse, out-of-row leads. Gate: (a) a minimum real length, (b) >= 2 real // kana/fullwidth-alnum chars, and (c) the decoded 2-byte units are >= 80% in // valid Shift-JIS text rows — so the always-on inventory stays a clean // enumeration of drawn strings and binary noise is dropped. bool sj_capture_worthy(const uint8_t* b, uint32_t n) { if (n < 4) return false; uint32_t units = 0, good = 0, kanafw = 0, unit_bytes = 0; for (uint32_t i = 0; i + 1 < n; ) { uint8_t c = b[i], d = b[i + 1]; if (sj_lead(c) && sj_trail(d)) { ++units; // Valid Shift-JIS text rows: 0x81 symbols/punct, 0x82 hiragana + // fullwidth alnum, 0x83 katakana, 0x88-0x9F + 0xE0-0xEA kanji. if (c == 0x81 || c == 0x82 || c == 0x83 || (c >= 0x88 && c <= 0x9F) || (c >= 0xE0 && c <= 0xEA)) { ++good; unit_bytes += 2; } if (c == 0x83 || (c == 0x82 && d >= 0x4F)) ++kanafw; // kana / fullwidth alnum i += 2; } else i += 1; } if (kanafw < 2) return false; // needs real JP / fullwidth content if (units == 0 || good * 5 < units * 4) return false; // >= 80% of units in valid rows if (unit_bytes * 2 < n) return false; // >= 50% of record is 2-byte SJIS text // (drops long single-byte fill buffers) return true; } // Canonical record-START gate (capture only). A capture is ingested only when // `va` is a genuine record boundary — the unit immediately preceding it is a // terminator (NUL / 0xFFFF) or a region edge. This collapses the partial-offset // explosion: a register that lands mid-string (or mid-binary-struct) is preceded // by content bytes, not a terminator, so the SAME record is no longer re-ingested // at every byte offset a register happens to point. APPLY is unaffected — it must // still match the exact bytes the game draws at whatever offset the register // carries, so this gate is never consulted on the apply path. inline bool sj_at_record_start(uint8_t* ram, uint32_t va) { if (!va_in_ram(va - 1)) return true; // region edge uint8_t p1 = grb(ram, va - 1); if (p1 == 0x00) return true; // after NUL terminator if (p1 == 0xFF && grb(ram, va - 2) == 0xFF) return true; // after 0xFFFF terminator return false; // mid-record offset — skip } // Struct-vs-text safety: a genuine message record contains only text units and // the known control framing (0x0A newline; 0x10/0x30 page-break leads; 0xFC/0xFE/ // 0xFF framing; fullwidth-space pad). A fixed-width struct field packs binary // params after the text (e.g. the "Tutorial N" label's trailing 09 08 01 02 F4), // which shows up as low-control bytes 0x01..0x09 / 0x0B..0x0F / 0x11..0x1F. // Replacing such a record corrupts the struct and derails the game — so apply is // refused for records carrying those wild bytes even when a table entry exists. bool sj_clean_text(const uint8_t* b, uint32_t n) { for (uint32_t i = 0; i < n; ++i) { uint8_t c = b[i]; if (sj_lead(c) && i + 1 < n && sj_trail(b[i + 1])) { ++i; continue; } if (c == 0x0A || c == 0x10 || c == 0x30) continue; // newline / page-break leads if (c >= 0x20) continue; // text / framing / high bytes return false; // wild low-control => struct } return true; } const EncodingProfile kShiftJisProfile = { "shift_jis", sj_first_byte_textish, sj_read_record, sj_transcode, sj_capture_worthy }; // =========================================================================== // Module state // =========================================================================== struct TableEntry { std::string target; // active-language UTF-8 target (empty => fall back to source) Term term = Term::Nul; }; struct CapRec { uint8_t bytes[96]; uint8_t sample_len; // bytes stored in `bytes` (<=96) uint16_t full_len; // full record length uint32_t addr; // last source VA seen uint32_t pc; // a dispatch target that carried it uint32_t ra; uint32_t first_frame; uint64_t count; Term term; // terminator that delimited the record (nul|ffff) bool translated; }; // A per-glyph label source-patch entry. Some UI text (level/stage names) is // drawn glyph-by-glyph as sprites from a fixed-stride, space-padded table in the // EXE — there is NO string pointer and NO terminator, so the dispatch/arg-scan // message hook above can never see it. Instead we patch the label's bytes in // guest RAM once the EXE region is resident: overwrite the confirmed slot with // the target-language text (fullwidth-Latin Shift-JIS, little-endian) re-padded // to the exact slot width, and the game's OWN per-glyph routine then draws // English. Verify-before-patch (RAM must equal `src` first) guarantees we never // write unless the exact expected JP label is present, and writing exactly // `width` bytes (== the table stride) guarantees we never spill into the next // slot. See docs/STRING_TRANSLATION.md Appendix Y. struct GlyphLabel { uint32_t addr = 0; // slot base VA uint32_t width = 0; // slot stride in bytes (never exceeded) std::vector src; // expected JP source bytes (verify key) std::string target; // active-language UTF-8 (empty => skip) bool patched = false; }; // A VRAM-strip patch entry. Some UI text is pre-rendered pixels inside a // texture asset the game uploads to VRAM and samples with quads (Tsumu's HUD // labels live in the glyph-sheet image) — no glyph codes exist anywhere, so // neither the message hook nor the glyph-label patch can reach it. The patch // rides the game's own upload path instead: whenever a GP0 0xA0 CPU→VRAM // transfer completes and fully contains the patch rect, the rect is verified // halfword-for-halfword against the expected JP pixels and, only on an exact // match, rewritten with the target-language pixels through the renderer's own // VRAM write facade. Because it re-applies on every matching upload it is a // source-path patch (survives scene reloads / re-uploads), not a one-shot // VRAM poke. struct VramPatch { int x = 0, y = 0; // rect origin, VRAM halfword coords int w = 0, h = 0; // rect size (halfwords × rows) std::vector src; // expected JP pixels (verify key), w*h std::vector rep; // replacement pixels, w*h uint64_t applied = 0; // times applied (per-upload re-apply) }; // Table-driven message record. Some UI text is addressed through a POINTER // TABLE the renderer indexes internally — e.g. Tsumu's memory-card dialogs: // show_card_dialog(id) loads table[id] and draws it, so the string pointer is // never a dispatch argument and the a0..a3 message hook can never see it (nor // can it be captured). The fix is the glyph-label pattern applied to an SJIS // message record: verify the exact JP bytes are resident at a known address, // then overwrite them IN PLACE with the transcoded target text (little-endian // fullwidth-Shift-JIS body + the game's FE-FF line-break / 30-FC page-break / // 10-FC prompt framing), terminated, never exceeding the source byte length — // so the game's own message renderer reads the translation. Verify-before- // patch + the length cap guarantee no corruption; idempotent (a patched slot // no longer matches `src`). Authored via a [[entry]] carrying a `ram_addr`. struct MsgInplace { uint32_t addr = 0; // message record VA (game data region) std::vector src; // expected JP record bytes (verify key) std::string target; // active-language UTF-8 Term term = Term::Nul; // record terminator (nul / ffff) bool patched = false; }; const EncodingProfile* g_prof = &kShiftJisProfile; std::unordered_map g_table; // hash -> translation std::unordered_map g_inv; // hash -> capture record std::vector g_glyph_labels;// per-glyph RAM patches std::atomic g_glyph_pending{0}; // unpatched count (0 => idle) std::vector g_vram_patches; // pre-rendered strip patches std::atomic g_vram_patch_n{0}; // count (0 => upload hook idle) std::vector g_msg_inplace; // table-driven message RAM patches std::atomic g_msg_inplace_pending{0}; // unpatched count (0 => idle) // Card-manager messages are packed into NUL-terminated *chunks*; within a chunk, // distinct messages (each with its own pointer-table entry) are separated ONLY by // runs of the fullwidth space (0x8140), with no terminator between them. The // game's renderer reads a message from its pointer until the next NUL, so any // non-last message in a chunk bleeds through its neighbours into the chunk tail // (e.g. the "Reading Card" / "Saving" status messages rendered the format prompt // and its Yes/No wait). A separator drops a NUL at the start of the trailing // space-run so each message reads to its own terminator. Verify-then-patch: only // a resident fullwidth space is ever overwritten, so drift can never corrupt text. struct NulSep { uint32_t addr = 0; bool patched = false; }; std::vector g_msg_seps; // inter-message NUL separators std::atomic g_msg_sep_pending{0}; // unpatched count (0 => idle) std::mutex g_mtx; std::atomic g_apply_armed{false}; // table non-empty AND language enabled std::atomic g_capture_on{false}; // inventory capture DEFAULT OFF: // the always-on Shift-JIS string-inventory scan costs 26-35% of // whole-lane throughput on streaming-heavy titles (measured across // three GT2 race lanes; reproduced fleet-wide). Substitution (table + // language armed) is unaffected; PSX_XLATE_CAPTURE=1 re-enables the // scan for translation authoring. std::atomic g_calls{0}; std::atomic g_hits{0}; std::string g_lang = "en"; std::string g_dir; // translations/ directory (for reload) // Recently-written scratch VAs — so our own transcoded (fullwidth-SJIS) English // scratch isn't re-captured as a bogus "Japanese" record. uint32_t g_scratch_ring[32] = {0}; std::atomic g_scratch_pos{0}; bool scratch_recent(uint32_t va) { for (uint32_t i = 0; i < 32; ++i) { uint32_t s = g_scratch_ring[i]; if (s && (va >= s) && (va < s + kSrcMax + 4)) return true; } return false; } void scratch_note(uint32_t va) { g_scratch_ring[g_scratch_pos.fetch_add(1, std::memory_order_relaxed) & 31u] = va; } // --------------------------------------------------------------------------- // Table loading (all translations/*.toml). Hot-reloadable via debug "reload". // --------------------------------------------------------------------------- std::vector hex_to_bytes(const std::string& hex) { std::vector b; auto hv = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; if (c >= 'A' && c <= 'F') return c - 'A' + 10; return -1; }; for (size_t i = 0; i + 1 < hex.size(); i += 2) { int hi = hv(hex[i]), lo = hv(hex[i+1]); if (hi < 0 || lo < 0) { b.clear(); break; } b.push_back((uint8_t)((hi << 4) | lo)); } return b; } // hex string -> little-endian halfwords (VRAM pixel stream order). std::vector hex_to_halfwords(const std::string& hex) { auto b = hex_to_bytes(hex); std::vector hw; if (b.empty() || (b.size() & 1)) return hw; hw.reserve(b.size() / 2); for (size_t i = 0; i + 1 < b.size(); i += 2) hw.push_back((uint16_t)(b[i] | (b[i + 1] << 8))); return hw; } void load_tables_locked() { g_table.clear(); g_glyph_labels.clear(); g_glyph_pending.store(0, std::memory_order_relaxed); g_vram_patches.clear(); g_vram_patch_n.store(0, std::memory_order_relaxed); g_msg_inplace.clear(); g_msg_inplace_pending.store(0, std::memory_order_relaxed); g_msg_seps.clear(); g_msg_sep_pending.store(0, std::memory_order_relaxed); for (auto& kv : g_inv) kv.second.translated = false; if (g_dir.empty() || !fs::exists(g_dir)) { g_apply_armed.store(false); return; } const bool lang_off = g_lang.empty() || g_lang == "jp" || g_lang == "off"; size_t files = 0, entries = 0, glyphs = 0, vpatches = 0; std::error_code ec; for (auto& de : fs::directory_iterator(g_dir, ec)) { if (!de.is_regular_file()) continue; if (de.path().extension() != ".toml") continue; toml::value data; try { data = toml::parse(de.path().string()); } catch (...) { continue; } ++files; /* A fixes-only file (vram_patch / glyph-label / msg blocks, no string * table) is valid: gate each block on its own key, never the file. */ const auto arr = data.contains("entry") ? toml::find(data, "entry") : toml::value(toml::array{}); if (arr.is_array()) for (const auto& e : arr.as_array()) { if (!e.contains("src_hex")) continue; std::string hex = toml::find_or(e, "src_hex", ""); auto bytes = hex_to_bytes(hex); if (bytes.empty()) continue; std::string tgt; if (e.contains(g_lang)) tgt = toml::find_or(e, g_lang, ""); else if (e.contains("en")) tgt = toml::find_or(e, "en", ""); if (tgt.empty()) continue; // no translation for this lang => source shown Term term = Term::Nul; std::string ts = toml::find_or(e, "term", "nul"); if (ts == "ffff" || ts == "FFFF") term = Term::FFFF; else if (ts == "none") term = Term::None; // mid-blob sub-record: no // terminator write (content follows) // Optional in-place RAM patch: a record the game addresses through a // pointer table (never a dispatch arg) can't be reached by the a0..a3 // hook, so overwrite the JP bytes in place at `ram_addr`. The entry // still joins g_table (harmless if the record is also ever passed). if (e.contains("ram_addr")) { uint32_t ra = (uint32_t)toml::find_or(e, "ram_addr", 0); if (ra) g_msg_inplace.push_back(MsgInplace{ ra, bytes, tgt, term, false }); } uint64_t key = fnv1a(bytes.data(), (uint32_t)bytes.size()); g_table[key] = TableEntry{ std::move(tgt), term }; ++entries; } // Per-glyph label source-patch entries (RAM-patch layer). Only load when // a target for the active language exists; otherwise the slot stays JP. if (!lang_off && data.contains("glyph_label")) { const auto& garr = toml::find(data, "glyph_label"); if (garr.is_array()) for (const auto& g : garr.as_array()) { if (!g.contains("addr") || !g.contains("src_hex")) continue; std::string ghex = toml::find_or(g, "src_hex", ""); auto gbytes = hex_to_bytes(ghex); if (gbytes.empty()) continue; std::string tgt; if (g.contains(g_lang)) tgt = toml::find_or(g, g_lang, ""); else if (g.contains("en")) tgt = toml::find_or(g, "en", ""); if (tgt.empty()) continue; // no translation for this lang => slot stays JP GlyphLabel gl; gl.addr = (uint32_t)toml::find_or(g, "addr", 0); gl.width = (uint32_t)toml::find_or(g, "width", (int64_t)gbytes.size()); if (gl.addr == 0 || gl.width == 0 || gbytes.size() > gl.width) continue; gl.src = std::move(gbytes); gl.target = std::move(tgt); g_glyph_labels.push_back(std::move(gl)); ++glyphs; } } // VRAM-strip patch entries (pre-rendered-pixel layer). The replacement // pixels come from a per-language "_hex" key ("en_hex" fallback); // no key for the active language => the strip stays JP. if (!lang_off && data.contains("vram_patch")) { const auto& varr = toml::find(data, "vram_patch"); if (varr.is_array()) for (const auto& v : varr.as_array()) { if (!v.contains("src_hex")) continue; VramPatch vp; vp.x = (int)toml::find_or(v, "x", -1); vp.y = (int)toml::find_or(v, "y", -1); vp.w = (int)toml::find_or(v, "w", 0); vp.h = (int)toml::find_or(v, "h", 0); if (vp.x < 0 || vp.y < 0 || vp.w <= 0 || vp.h <= 0) continue; if (vp.x + vp.w > 1024 || vp.y + vp.h > 512) continue; vp.src = hex_to_halfwords(toml::find_or(v, "src_hex", "")); std::string rk = g_lang + "_hex"; if (v.contains(rk)) vp.rep = hex_to_halfwords(toml::find_or(v, rk, "")); else if (v.contains("en_hex")) vp.rep = hex_to_halfwords(toml::find_or(v, "en_hex", "")); size_t need = (size_t)vp.w * (size_t)vp.h; if (vp.src.size() != need || vp.rep.size() != need) continue; g_vram_patches.push_back(std::move(vp)); ++vpatches; } } // Inter-message NUL separators (structure fix for packed message chunks). // Each entry names a VA that currently holds a fullwidth space at the start // of an inter-message gap; a NUL is dropped there so the preceding message // reads to its own terminator instead of bleeding into its neighbour. if (!lang_off && data.contains("msg_sep")) { const auto& sarr = toml::find(data, "msg_sep"); if (sarr.is_array()) for (const auto& s : sarr.as_array()) { if (!s.contains("addr")) continue; uint32_t a = (uint32_t)toml::find_or(s, "addr", 0); if (a) g_msg_seps.push_back(NulSep{ a, false }); } } } g_glyph_pending.store((int)g_glyph_labels.size(), std::memory_order_relaxed); g_vram_patch_n.store((int)g_vram_patches.size(), std::memory_order_relaxed); g_msg_inplace_pending.store((int)g_msg_inplace.size(), std::memory_order_relaxed); g_msg_sep_pending.store((int)g_msg_seps.size(), std::memory_order_relaxed); g_apply_armed.store(!lang_off && (!g_table.empty() || !g_glyph_labels.empty() || !g_vram_patches.empty() || !g_msg_inplace.empty() || !g_msg_seps.empty()), std::memory_order_relaxed); // Mark inventory records that now have a translation. for (auto& kv : g_inv) kv.second.translated = (g_table.find(kv.first) != g_table.end()); std::fprintf(stderr, "[xlate] loaded %zu entries + %zu glyph-labels + %zu vram-patches + %zu msg-inplace from %zu file(s) in %s (lang=%s apply=%s)\n", entries, glyphs, vpatches, g_msg_inplace.size(), files, g_dir.c_str(), g_lang.c_str(), g_apply_armed.load() ? "on" : "off"); } // --------------------------------------------------------------------------- // Capture inventory upsert. // --------------------------------------------------------------------------- void inv_upsert_locked(uint64_t key, const uint8_t* bytes, uint32_t len, uint32_t addr, uint32_t pc, uint32_t ra, Term term) { auto it = g_inv.find(key); if (it != g_inv.end()) { it->second.count++; it->second.addr = addr; it->second.pc = pc; it->second.ra = ra; return; } if (g_inv.size() >= 60000) return; // sane bound CapRec r{}; uint32_t n = len < 96 ? len : 96; std::memcpy(r.bytes, bytes, n); r.sample_len = (uint8_t)n; r.full_len = (uint16_t)len; r.term = term; r.addr = addr; r.pc = pc; r.ra = ra; r.first_frame = (uint32_t)s_frame_count; r.count = 1; r.translated = (g_table.find(key) != g_table.end()); g_inv[key] = r; } // --------------------------------------------------------------------------- // Glyph-label RAM source-patch: scan the label table and patch any slot whose // confirmed JP source bytes are currently resident in guest RAM. Idempotent — // a patched slot no longer matches `src`, so it is written exactly once. Runs // under g_mtx. // --------------------------------------------------------------------------- void glyph_labels_patch_locked(uint8_t* ram) { int pending = 0; for (auto& gl : g_glyph_labels) { if (gl.patched || gl.target.empty() || gl.width == 0) continue; // Verify-before-patch: the exact expected JP label must be resident and // the whole slot must fit in RAM. If not, the region isn't loaded yet — // leave it JP and try again on a later scan (never corrupt). if (!va_in_ram(gl.addr) || !va_in_ram(gl.addr + gl.width - 1)) { ++pending; continue; } bool match = gl.src.size() <= gl.width; for (uint32_t i = 0; match && i < gl.src.size(); ++i) if (grb(ram, gl.addr + i) != gl.src[i]) match = false; if (!match) { ++pending; continue; } std::vector enc; label_transcode_le(gl.target, gl.width, enc); if (enc.empty() || enc.size() > gl.width) { ++pending; continue; } // paranoia for (uint32_t i = 0; i < enc.size(); ++i) gwb(ram, gl.addr + i, enc[i]); bless_patch(ram, gl.addr, (uint32_t)enc.size()); gl.patched = true; } g_glyph_pending.store(pending, std::memory_order_relaxed); } // Build a message body: little-endian fullwidth-Shift-JIS (as the message-table // drawer reads it) with the author's \n/\f/\r framing tokens mapped to the // game's control codes (FE-FF line break, 30-FC page break, 10-FC prompt). // Mirrors sj_transcode's glyph mapping + apply_to_reg's framing, fused for the // in-place path (which writes the framed bytes directly rather than repointing). void msg_inplace_build(const std::string& utf8, std::vector& out) { out.clear(); size_t i = 0; while (i < utf8.size()) { uint8_t c = (uint8_t)utf8[i]; uint32_t cp; size_t adv; if (c < 0x80) { cp = c; adv = 1; } else if ((c & 0xE0) == 0xC0 && i + 1 < utf8.size()) { cp = ((c & 0x1F) << 6) | (utf8[i+1] & 0x3F); adv = 2; } else if ((c & 0xF0) == 0xE0 && i + 2 < utf8.size()) { cp = ((c & 0x0F) << 12) | ((utf8[i+1] & 0x3F) << 6) | (utf8[i+2] & 0x3F); adv = 3; } else { cp = ' '; adv = 1; } i += adv; if (cp == '\n') { out.push_back(0xFE); out.push_back(0xFF); continue; } // line break if (cp == 0x0C) { out.push_back(0x30); out.push_back(0xFC); continue; } // \f page break if (cp == 0x0D) { out.push_back(0x10); out.push_back(0xFC); continue; } // \r prompt/wait if (cp >= 0xFF01 && cp <= 0xFF5E) cp = cp - 0xFF00 + 0x20; // fullwidth Latin -> ASCII if (cp == 0x3000) cp = ' '; uint16_t g = fw_sjis_for_ascii(cp <= 0x7E ? cp : ' '); out.push_back((uint8_t)(g & 0xFF)); // little-endian storage out.push_back((uint8_t)(g >> 8)); } } // Table-driven message in-place RAM patch. Mirrors glyph_labels_patch_locked // but for SJIS message records. Runs under g_mtx. void msg_inplace_patch_locked(uint8_t* ram) { int pending = 0; for (auto& m : g_msg_inplace) { if (m.patched || m.target.empty() || m.src.empty()) continue; uint32_t len = (uint32_t)m.src.size(); if (!va_in_ram(m.addr) || !va_in_ram(m.addr + len - 1)) { ++pending; continue; } // Verify-before-patch: the exact JP record must be resident (else the // region isn't loaded yet, or it's already patched — never corrupt). bool match = true; for (uint32_t i = 0; match && i < len; ++i) if (grb(ram, m.addr + i) != m.src[i]) match = false; if (!match) { ++pending; continue; } std::vector body; msg_inplace_build(m.target, body); // Structure-preserving in-place patch. The author writes the SAME control // framing as the source (\n \f \r), so an English body sized to the exact // source length reproduces the record's control layout — critically the // trailing \r (0x10-FC prompt/wait) an interactive dialog needs to accept // input and advance. Truncate to whole 2-byte cells if over-long, then pad // with the fullwidth space to the source length so no stale JP tail // remains, and reuse the record's own terminator slot (offset len) so the // NUL/0xFFFF lands exactly where the game expects it. while (!body.empty() && body.size() > len) body.resize(body.size() - 2); if (body.empty()) { m.patched = true; continue; } // nothing fit; don't spin // term=none marks a mid-blob sub-record (e.g. a Yes/No prompt whose \r is // followed by another message in the same NUL/FFFF span): write the body // exactly, WITHOUT padding or a terminator, so the following content is // left intact. Otherwise pad to the record length and stamp the terminator // in the record's own terminator slot. if (m.term != Term::None) while (body.size() + 2 <= len) { body.push_back(0x40); body.push_back(0x81); } for (uint32_t i = 0; i < body.size(); ++i) gwb(ram, m.addr + i, body[i]); uint32_t ti = (uint32_t)body.size(); if (m.term == Term::FFFF) { if (va_in_ram(m.addr + ti + 1)) { gwb(ram, m.addr + ti, 0xFF); gwb(ram, m.addr + ti + 1, 0xFF); } } else if (m.term == Term::Nul && va_in_ram(m.addr + ti)) { gwb(ram, m.addr + ti, 0x00); } // Bless the whole written span (body + up to a 2-byte terminator) so the // guard sees the patched record as intentional data, not modified code. bless_patch(ram, m.addr, ti + 2); m.patched = true; } g_msg_inplace_pending.store(pending, std::memory_order_relaxed); } // Drop an end-of-message terminator (0xFFFF) at each inter-message separator VA // once the region is resident. The text renderer (FUN at 0x80058xxx, message // pointer at gp+0x26c) stops a message at the \r prompt-wait (0xFC10) or the // 0xFFFF end marker — NOT at 0x0000 (that byte is drawn as a glyph). 0xFFFF ends // the message with no input-wait (as the game's own term=ffff status messages // do), so the preceding packed message reads to its own terminator instead of // bleeding through its neighbours to the next \r. Verify-then-patch: the two // bytes must currently be a fullwidth space (LE 0x40 0x81) — never overwrite // text — so an already-patched or drifted slot is simply skipped. void msg_seps_patch_locked(uint8_t* ram) { int pending = 0; for (auto& s : g_msg_seps) { if (s.patched) continue; if (!va_in_ram(s.addr) || !va_in_ram(s.addr + 1)) { ++pending; continue; } if (grb(ram, s.addr) != 0x40 || grb(ram, s.addr + 1) != 0x81) { ++pending; continue; } gwb(ram, s.addr, 0xFF); gwb(ram, s.addr + 1, 0xFF); bless_patch(ram, s.addr, 2); s.patched = true; } g_msg_sep_pending.store(pending, std::memory_order_relaxed); } // --------------------------------------------------------------------------- // VRAM-strip patch: verify the rect holds the exact expected JP pixels, then // rewrite it with the target-language pixels through the renderer facade. // `force` skips the upload-containment gate (debug "vpatch" re-apply). Runs // under g_mtx. Returns patches applied this call. // --------------------------------------------------------------------------- int vram_patches_apply_locked(int ux, int uy, int uw, int uh, bool force) { int applied = 0; for (auto& vp : g_vram_patches) { if (!force && (vp.x < ux || vp.y < uy || vp.x + vp.w > ux + uw || vp.y + vp.h > uy + uh)) continue; // Verify-before-patch: every halfword must match the expected JP // pixels. A different asset at the same coords (or an already-patched // strip) fails the compare and is left untouched. bool match = true; for (int r = 0; match && r < vp.h; ++r) for (int c = 0; match && c < vp.w; ++c) if (gr_vram_read(vp.x + c, vp.y + r) != vp.src[(size_t)r * vp.w + c]) match = false; if (!match) continue; for (int r = 0; r < vp.h; ++r) for (int c = 0; c < vp.w; ++c) gr_vram_write(vp.x + c, vp.y + r, vp.rep[(size_t)r * vp.w + c]); vp.applied++; ++applied; } return applied; } // --------------------------------------------------------------------------- // Apply: transcode + write guest scratch + repoint the arg register. // --------------------------------------------------------------------------- bool apply_to_reg(uint8_t* ram, CPUState* cpu, uint32_t sp, uint32_t* reg, const TableEntry& te, Term src_term) { std::vector enc; g_prof->transcode(te.target, enc); if (enc.empty()) return false; Term t = (te.term != Term::Nul) ? te.term : src_term; // preserve source terminator // For framed (0xFFFF) messages the game's line break is the "FE FF" code, not // a raw 0x0A — remap so multi-line English reflows in the text box. std::vector body; body.reserve(enc.size() + 16); for (uint8_t c : enc) { if (c == 0x0A && t == Term::FFFF) { body.push_back(0xFE); body.push_back(0xFF); } // line break else if (c == 0x0A) { /* NUL record: drop stray newline */ } else if (c == 0x0C) { body.push_back(0x30); body.push_back(0xFC); } // \f page break else if (c == 0x0D) { body.push_back(0x10); body.push_back(0xFC); } // \r prompt/wait else body.push_back(c); } if (body.empty() || body.size() > kSrcMax) return false; // Scratch below the caller's $sp, 8-byte aligned, with headroom. if (sp < 0x80001000u) return false; uint32_t scratch = (sp - 0x900u) & ~7u; if (!va_in_ram(scratch) || !va_in_ram(scratch + (uint32_t)body.size() + 2)) return false; for (size_t i = 0; i < body.size(); ++i) gwb(ram, scratch + (uint32_t)i, body[i]); if (t == Term::FFFF) { gwb(ram, scratch + (uint32_t)body.size(), 0xFF); gwb(ram, scratch + (uint32_t)body.size() + 1, 0xFF); } else { gwb(ram, scratch + (uint32_t)body.size(), 0x00); } scratch_note(scratch); *reg = scratch; return true; } } // namespace // =========================================================================== // Public API // =========================================================================== extern "C" void text_xlate_on_dispatch(CPUState* cpu, uint32_t target) { const bool cap = g_capture_on.load(std::memory_order_relaxed); const bool app = g_apply_armed.load(std::memory_order_relaxed); if (!cap && !app) return; uint8_t* ram = memory_get_ram_ptr(); if (!ram) return; uint64_t call_n = g_calls.fetch_add(1, std::memory_order_relaxed); // Per-glyph label RAM source-patch (load-time). Runs while any label is still // unpatched — i.e. until the EXE label region becomes resident and every // confirmed slot has been overwritten. Once g_glyph_pending hits 0 this is a // single relaxed load on the hot path. Throttled so the (cheap) verify scan // costs nothing per-dispatch before the region loads. if (app && g_glyph_pending.load(std::memory_order_relaxed) > 0 && (call_n & 0x1FFu) == 0) { std::lock_guard lk(g_mtx); glyph_labels_patch_locked(ram); } // Table-driven message records (never a dispatch arg): patch in place while // any is still unpatched. Same cheap throttle as glyph labels. if (app && g_msg_inplace_pending.load(std::memory_order_relaxed) > 0 && (call_n & 0x1FFu) == 0) { std::lock_guard lk(g_mtx); msg_inplace_patch_locked(ram); } // Inter-message NUL separators: same cheap throttle; idle once all placed. if (app && g_msg_sep_pending.load(std::memory_order_relaxed) > 0 && (call_n & 0x1FFu) == 0) { std::lock_guard lk(g_mtx); msg_seps_patch_locked(ram); } const uint32_t sp = cpu->gpr[29]; // Scan the argument registers a0..a3 for source-text pointers. KV-gated // apply means swapping only the reg that pointed at a KNOWN record — a // coincidental non-text arg won't be in the table, so it is never touched. uint32_t* argregs[4] = { &cpu->gpr[4], &cpu->gpr[5], &cpu->gpr[6], &cpu->gpr[7] }; for (int a = 0; a < 4; ++a) { uint32_t va = *argregs[a]; if (!va_in_ram(va)) continue; if (!g_prof->first_byte_textish(grb(ram, va))) continue; if (scratch_recent(va)) continue; // don't recapture our own English scratch uint8_t buf[kSrcMax]; uint32_t len = 0; Term term = Term::None; if (!g_prof->read_record(ram, va, buf, &len, &term)) continue; uint64_t key = fnv1a(buf, len); // CAPTURE (always-on inventory): ingest each real string ONCE at its // genuine record start, and only if it clears the noise gate. The // canonical-start test collapses the partial-offset explosion (the same // record was previously re-ingested at every offset a register landed on). // APPLY below is deliberately NOT gated by record-start — it matches the // exact bytes the game draws wherever the pointer lands. if (cap && sj_at_record_start(ram, va) && (!g_prof->capture_worthy || g_prof->capture_worthy(buf, len))) { std::lock_guard lk(g_mtx); inv_upsert_locked(key, buf, len, va, target, cpu->gpr[31], term); } if (app) { // Safety: by default only substitute standalone framed (0xFFFF) // messages. NUL-terminated records are frequently fixed-width struct // fields with binary params packed after the text (e.g. the "Tutorial // N" label carries trailing coordinate bytes); replacing those // corrupts the struct and derails the game. A NUL record is only // safe when its table entry opts in with term="nul" AND matches // byte-for-byte (the author vetted it). PSX_XLATE_ALLOW_NUL=1 lifts // the gate for experimentation. TableEntry te; { std::lock_guard lk(g_mtx); auto it = g_table.find(key); if (it == g_table.end()) continue; te = it->second; } // The table entry is author-vetted, but guard against corrupting a // struct that shares the key: apply only when the source record is // framed (0xFFFF, always standalone) OR pure clean text (no wild // binary params). PSX_XLATE_ALLOW_NUL=1 forces through for debugging. static const bool allow_nul = [] { const char* e = std::getenv("PSX_XLATE_ALLOW_NUL"); return e && e[0] == '1'; }(); if (term != Term::FFFF && !allow_nul && !sj_clean_text(buf, len)) continue; if (apply_to_reg(ram, cpu, sp, argregs[a], te, term)) g_hits.fetch_add(1, std::memory_order_relaxed); } } } extern "C" void text_xlate_vram_upload(int x, int y, int w, int h) { // Hot path: one relaxed load when no patches are configured / lang off. if (g_vram_patch_n.load(std::memory_order_relaxed) <= 0) return; if (!g_apply_armed.load(std::memory_order_relaxed)) return; std::lock_guard lk(g_mtx); vram_patches_apply_locked(x, y, w, h, false); } extern "C" void text_xlate_init(const char* project_root, const char* language) { const char* env = std::getenv("PSX_LANG"); if (env && *env) g_lang = env; else if (language && *language) g_lang = language; if (project_root && *project_root) g_dir = (fs::path(project_root) / "translations").string(); const char* capenv = std::getenv("PSX_XLATE_CAPTURE"); if (capenv && capenv[0] == '0') g_capture_on.store(false); else if (capenv && capenv[0] == '1') g_capture_on.store(true); std::lock_guard lk(g_mtx); load_tables_locked(); } extern "C" void text_xlate_set_language(const char* language) { // PSX_LANG env is an authoring/testing override — if set it pins the language // and the launcher's choice is ignored (matches text_xlate_init precedence). const char* env = std::getenv("PSX_LANG"); std::string want = (env && *env) ? env : (language ? language : ""); std::lock_guard lk(g_mtx); if (want == g_lang) return; // no change => skip the reload g_lang = want; load_tables_locked(); // re-selects the target column + re-arms APPLY } extern "C" int text_xlate_debug_json(const char* subcmd, char* out, int cap) { if (!out || cap <= 0) return 0; std::string sc = subcmd ? subcmd : "stats"; std::lock_guard lk(g_mtx); if (sc == "reload") { load_tables_locked(); return std::snprintf(out, cap, "{\"reloaded\":true,\"entries\":%zu}", g_table.size()); } if (sc == "stats") { size_t gp = 0; for (auto& gl : g_glyph_labels) if (gl.patched) ++gp; uint64_t va = 0; for (auto& vp : g_vram_patches) va += vp.applied; size_t mp = 0; for (auto& m : g_msg_inplace) if (m.patched) ++mp; return std::snprintf(out, cap, "{\"lang\":\"%s\",\"apply\":%s,\"capture\":%s,\"table_entries\":%zu," "\"distinct_captured\":%zu,\"calls\":%llu,\"hits\":%llu," "\"glyph_labels\":%zu,\"glyph_patched\":%zu,\"glyph_pending\":%d," "\"vram_patches\":%zu,\"vram_applied\":%llu," "\"msg_inplace\":%zu,\"msg_inplace_patched\":%zu,\"msg_inplace_pending\":%d}", g_lang.c_str(), g_apply_armed.load() ? "true" : "false", g_capture_on.load() ? "true" : "false", g_table.size(), g_inv.size(), (unsigned long long)g_calls.load(), (unsigned long long)g_hits.load(), g_glyph_labels.size(), gp, g_glyph_pending.load(), g_vram_patches.size(), (unsigned long long)va, g_msg_inplace.size(), mp, g_msg_inplace_pending.load()); } // vpatch: force-apply every VRAM-strip patch against current VRAM content // (verify still gates each one) and report per-patch status. Lets a freshly // reloaded patch be exercised without waiting for the game to re-upload. if (sc == "vpatch") { int n = g_apply_armed.load() ? vram_patches_apply_locked(0, 0, 0, 0, true) : 0; int w = 0; w += std::snprintf(out + w, cap - w, "{\"applied_now\":%d,\"patches\":[", n); bool first = true; for (auto& vp : g_vram_patches) { if (w > cap - 160) break; if (!first) w += std::snprintf(out + w, cap - w, ","); first = false; w += std::snprintf(out + w, cap - w, "{\"x\":%d,\"y\":%d,\"w\":%d,\"h\":%d,\"applied\":%llu}", vp.x, vp.y, vp.w, vp.h, (unsigned long long)vp.applied); } w += std::snprintf(out + w, cap - w, "]}"); return w; } // glyph: per-label patch status (JP addr -> patched? + target). Observability // for the RAM source-patch layer. if (sc == "glyph") { int w = 0; w += std::snprintf(out + w, cap - w, "["); bool first = true; for (auto& gl : g_glyph_labels) { if (w > cap - 256) break; if (!first) w += std::snprintf(out + w, cap - w, ","); first = false; w += std::snprintf(out + w, cap - w, "{\"addr\":\"%08x\",\"width\":%u,\"patched\":%s,\"en\":\"%s\"}", gl.addr, gl.width, gl.patched ? "true" : "false", gl.target.c_str()); } w += std::snprintf(out + w, cap - w, "]"); return w; } // dump / todo: JSON array of captured records. const bool todo_only = (sc == "todo"); int w = 0; w += std::snprintf(out + w, cap - w, "["); bool first = true; for (auto& kv : g_inv) { const CapRec& r = kv.second; if (todo_only && r.translated) continue; if (w > cap - 512) break; // leave room; truncate cleanly if (!first) w += std::snprintf(out + w, cap - w, ","); first = false; w += std::snprintf(out + w, cap - w, "{\"hash\":\"%016llx\",\"addr\":\"%08x\",\"pc\":\"%08x\",\"ra\":\"%08x\"," "\"len\":%u,\"count\":%llu,\"term\":\"%s\",\"xl\":%s,\"hex\":\"", (unsigned long long)kv.first, r.addr, r.pc, r.ra, (unsigned)r.full_len, (unsigned long long)r.count, r.term == Term::FFFF ? "ffff" : "nul", r.translated ? "true" : "false"); for (uint32_t i = 0; i < r.sample_len && w < cap - 4; ++i) w += std::snprintf(out + w, cap - w, "%02x", r.bytes[i]); w += std::snprintf(out + w, cap - w, "\"}"); } w += std::snprintf(out + w, cap - w, "]"); return w; }