From a26cc2eea732d94faad585735f04cb52ce4ade72 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Mon, 28 Sep 2026 11:04:07 +0300 Subject: [PATCH] ludic.base: StrBuf and StrTable - a line written into a kept buffer and interned, one string per distinct text Co-Authored-By: Claude Opus 5.5 --- packages/ludic.base/README.md | 11 ++++ packages/ludic.base/index.ludic | 2 + packages/ludic.base/tests/text_test.ludic | 51 ++++++++++++++++++ packages/ludic.base/text_buf.ludic | 66 +++++++++++++++++++++++ packages/ludic.base/text_intern.ludic | 52 ++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 packages/ludic.base/tests/text_test.ludic create mode 100644 packages/ludic.base/text_buf.ludic create mode 100644 packages/ludic.base/text_intern.ludic diff --git a/packages/ludic.base/README.md b/packages/ludic.base/README.md index 5ededb78..4b98a415 100644 --- a/packages/ludic.base/README.md +++ b/packages/ludic.base/README.md @@ -178,6 +178,17 @@ program Game { Both are compiled and run by `tests/route_test.ludic` (the mechanics are `tests/toys/`). +## Text without allocating: `StrBuf` and `StrTable` + +Ludic frees nothing, so a line built every frame - a clock, a countdown, a prompt - grows the +program for as long as it runs. Write it into a kept `StrBuf` instead (`sb_clear`, `sb_add`, +`sb_int`, `sb_int2`, `sb_byte`, and `sb_pat(sb, "{1} of {2}", a, b, c, d)`, whose pattern stays a +literal at the call so a translator still finds it), then `sb_intern(sb, table)`: the `StrTable` +hands back ONE string per distinct text, looked up by content and copied out only the first time +those bytes are seen. What is handed on is the table's and never changes, so anything that keeps +a string as a key stays right. Growth stops once each text has been seen (1440 clock minutes, the +countdown's values); past `strs_new(most)` entries it falls back to a plain copy. + ## Tests Each piece has a program under `tests/`, and `ludic test` runs them all, every test block in a diff --git a/packages/ludic.base/index.ludic b/packages/ludic.base/index.ludic index ce4c44da..8a0c4cc3 100644 --- a/packages/ludic.base/index.ludic +++ b/packages/ludic.base/index.ludic @@ -4,6 +4,8 @@ module ludic_base uses numbers float import "tick.ludic" import "queue.ludic" +import "text_buf.ludic" +import "text_intern.ludic" import "rng.ludic" import "save.ludic" import "save_fields.ludic" diff --git a/packages/ludic.base/tests/text_test.ludic b/packages/ludic.base/tests/text_test.ludic new file mode 100644 index 00000000..69095ca3 --- /dev/null +++ b/packages/ludic.base/tests/text_test.ludic @@ -0,0 +1,51 @@ +# text_test.ludic - text written into a kept buffer, and interned: one string per distinct text +import "ludic.base" +program TextTest { + numbers float + test "a buffer takes text, numbers and a pattern, and cuts at its end rather than past it" { + let sb = sb_new(64) + sb_add(sb, "day ") + sb_int(sb, 42) + sb_add(sb, " at ") + sb_int2(sb, 7) + sb_byte(sb, 58) + sb_int2(sb, 5) + let tb = strs_new(16) + expect_eq(sb_intern(sb, tb), "day 42 at 07:05") + sb_clear(sb) + sb_int(sb, -310) + sb_add(sb, " ") + sb_int(sb, 0) + expect_eq(sb_intern(sb, tb), "-310 0") + sb_clear(sb) + sb_pat(sb, "[{1}] {2}: {3} minutes - room for {4} more", "E", "Trout", "4", "2") + expect_eq(sb_intern(sb, tb), "[E] Trout: 4 minutes - room for 2 more") + let small = sb_new(16) + sb_add(small, "a line longer than its buffer") + expect_eq(sb_len(small), 16) + } + test "the same bytes intern to the one string, made once; past the cap a plain copy" { + let sb = sb_new(32) + let tb = strs_new(3) + sb_add(sb, "08:03") + let a = sb_intern(sb, tb) + sb_clear(sb) + sb_add(sb, "08:0") + sb_int(sb, 3) + let b = sb_intern(sb, tb) + expect_eq(strs_count(tb), 1) + let ap: pointer = a + let bp: pointer = b + expect(ap == bp) + for i in 0 .. 5 { + sb_clear(sb) + sb_int(sb, i) + expect_eq(sb_intern(sb, tb), string(i)) + } + expect_eq(strs_count(tb), 3) + sb_clear(sb) + sb_add(sb, "08:03") + let c: pointer = sb_intern(sb, tb) + expect(c == ap) + } +} diff --git a/packages/ludic.base/text_buf.ludic b/packages/ludic.base/text_buf.ludic new file mode 100644 index 00000000..aed4a398 --- /dev/null +++ b/packages/ludic.base/text_buf.ludic @@ -0,0 +1,66 @@ +# text_buf.ludic - text written into a kept buffer: Ludic frees nothing, so a line built every +# frame (a clock, a countdown) is written here and interned (text_intern.ludic), never allocated +export property StrBuf { + b: []byte = null + n: int = 0 +} + +export function sb_new(cap: int) -> StrBuf { + let sb = new StrBuf + sb.b = buffer(Math.max(cap, 16)) + return sb +} + +export function sb_clear(sb: StrBuf) -> void { sb.n = 0 } +export function sb_len(sb: StrBuf) -> int { return sb.n } + +# a byte, dropped past the buffer's end: a line cut short, never a write out of it +export function sb_byte(sb: StrBuf, c: int) -> void { + if sb.n >= len(sb.b) { return } + sb.b[sb.n] = c + sb.n += 1 +} + +export function sb_add(sb: StrBuf, s: string) -> void { + if s == null { return } + let sp: string = s + for i in 0 .. len(sp) { sb_byte(sb, sp[i]) } +} + +# a whole number in decimal, a minus sign first +export function sb_int(sb: StrBuf, v: int) -> void { + var x = v + if x < 0 { + sb_byte(sb, 45) + x = 0 - x + } + var d = 1 + while d <= x / 10 { d = d * 10 } + while d >= 1 { + sb_byte(sb, 48 + (x / d) % 10) + d = d / 10 + } +} + +# a two-digit number, a leading zero below ten (a clock's hour and minute) +export function sb_int2(sb: StrBuf, v: int) -> void { + if v >= 0 and v < 10 { sb_byte(sb, 48) } + sb_int(sb, v) +} + +# a pattern with numbered holes, "{1} of {2}", its holes filled from a..d in order. The pattern is +# the message a translator sees, as a template literal's is, so it stays a literal at the call +export function sb_pat(sb: StrBuf, pat: string, a: string, b: string, c: string, d: string) -> void { + let p: string = pat + var i = 0 + while i < len(p) { + if p[i] == 123 and i + 2 < len(p) and p[i + 2] == 125 and p[i + 1] >= 49 and p[i + 1] <= 52 { + let k = p[i + 1] - 48 + if k == 1 { sb_add(sb, a) } else if k == 2 { sb_add(sb, b) } else if k == 3 { sb_add(sb, c) } else { sb_add(sb, d) } + i += 3 + } else { + sb_byte(sb, p[i]) + i += 1 + } + } +} diff --git a/packages/ludic.base/text_intern.ludic b/packages/ludic.base/text_intern.ludic new file mode 100644 index 00000000..62d4bc49 --- /dev/null +++ b/packages/ludic.base/text_intern.ludic @@ -0,0 +1,52 @@ +# text_intern.ludic - a kept buffer's bytes as ONE string per distinct text: looked up by content +# (FNV-1a, compared in place), copied out only the first time. Every string handed on is the +# table's and never changes, so a memo keyed on it stays right; past `most` texts a plain copy +export property StrTable { + hs: words = null # open addressing: each slot's hash, and its entry + 1 + at: words = null + ss: []string = new []string + most: int = 8192 +} + +export function strs_new(most: int) -> StrTable { + let tb = new StrTable + tb.most = Math.max(most, 1) + var cap = 16 + while cap < tb.most * 2 { cap = cap * 2 } + tb.hs = words(cap) + tb.at = words(cap) + return tb +} + +export function strs_count(tb: StrTable) -> int { return len(tb.ss) } + +function sb_hash(sb: StrBuf) -> int { + var h = -2128831035 + for i in 0 .. sb.n { h = (h ^ sb.b[i]) * 16777619 } + return h +} + +function sb_same(sb: StrBuf, s: string) -> bool { + let sp: string = s + if len(sp) != sb.n { return false } + for i in 0 .. sb.n { if sp[i] != sb.b[i] { return false } } + return true +} + +# the table's string for the buffer's bytes +export function sb_intern(sb: StrBuf, tb: StrTable) -> string { + let h = sb_hash(sb) + let mask = len(tb.hs) - 1 + var slot = h & mask + while tb.at[slot] != 0 { + let e = tb.at[slot] - 1 + if tb.hs[slot] == h and sb_same(sb, tb.ss[e]) { return tb.ss[e] } + slot = (slot + 1) & mask + } + let s = text_of(sb.b, sb.n) + if len(tb.ss) >= tb.most { return s } + push(tb.ss, s) + tb.hs[slot] = h + tb.at[slot] = len(tb.ss) + return s +}