Json.parse decodes an escaped string in one pass into one buffer - joined a character at a time, every shorter copy was kept, and a long escaped text took gigabytes (ui-preview: 6.5 GB in 3 s); protocol-v1.md states the @import path rule and the key a file override answers to

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 21:53:57 +03:00
parent ea51307eaa
commit 4fb1dc0ddc
3 changed files with 64 additions and 30 deletions

View file

@ -0,0 +1,6 @@
bump: patch
type: fix
**`Json.parse` decodes an escaped string in one pass.** A string with escapes in it was joined a character
at a time, and every shorter copy was kept: a long escaped text took gigabytes (ui-preview's story model
reached 6.5 GB in three seconds). It is now decoded into one buffer sized from the text. ui-preview's
protocol-v1.md states how an `@import` path is resolved and which key a `file` override answers to.

View file

@ -605,36 +605,67 @@ function jp_string(p: JP) -> string {
return p.s[a..p.n]
}
# the same with escapes in it, the rare case, a character at a time
# the same with escapes in it: decoded in one pass into one buffer, sized by the text up to the closing
# quote: an escape decodes to at most one and a half times its bytes (\uXXXX is six bytes and at most
# three out; a \u with bad hex is U+FFFD, three for two).
# Joining the result a character at a time kept every shorter copy - a long escaped text made
# gigabytes of them (ui-preview's model, 6.5 GB in three seconds).
function jp_string_esc(p: JP) -> string {
var out = ""
var end = p.i
while end < p.n and p.s[end] != '"' {
if p.s[end] == '\\' { end += 1 }
end += 1
}
if end > p.n { end = p.n }
let out = bytes((end - p.i) * 3 / 2 + 4) # a \u with bad hex is U+FFFD: three bytes for two
var w = 0
while p.i < p.n {
let c = p.s[p.i]
if c == '"' { p.i += 1; return out } # closing quote
if c == '"' { # closing quote
p.i += 1
out[w] = 0
let done: string = out
return done
}
if c == '\\' { # escape
p.i += 1
if p.i < p.n {
let e = p.s[p.i]
if e == 'n' { out += "\n" }
else if e == 't' { out += "\t" }
else if e == 'r' { out += "\r" }
else if e == 'b' or e == 'f' or e == 'u' {
var code = 8
if e == 'f' { code = 12 }
if e == 'u' { code = jp_code(p) } # leaves p.i on the escape's last hex digit
let u = jp_utf8(code)
out += u
free(u)
}
else { out += p.s[p.i..p.i + 1] } # \" \\ \/ -> the literal char
if e == 'n' { out[w] = 10; w += 1 }
else if e == 't' { out[w] = 9; w += 1 }
else if e == 'r' { out[w] = 13; w += 1 }
else if e == 'b' { out[w] = 8; w += 1 }
else if e == 'f' { out[w] = 12; w += 1 }
else if e == 'u' { w = jp_put_utf8(out, w, jp_code(p)) } # leaves p.i on the escape's last hex digit
else { out[w] = e; w += 1 } # \" \\ \/ -> the literal char
p.i += 1
}
} else {
out += p.s[p.i..p.i + 1]
out[w] = c
w += 1
p.i += 1
}
}
return out
out[w] = 0
let rest: string = out
return rest
}
# one code point's UTF-8 bytes into out at w; where the next one goes
function jp_put_utf8(out: bytes, w: int, c: int) -> int {
if c < 128 {
out[w] = c
return w + 1
}
if c < 2048 {
out[w] = 192 | (c >> 6); out[w + 1] = 128 | (c & 63)
return w + 2
}
if c < 65536 {
out[w] = 224 | (c >> 12); out[w + 1] = 128 | ((c >> 6) & 63); out[w + 2] = 128 | (c & 63)
return w + 3
}
out[w] = 240 | (c >> 18); out[w + 1] = 128 | ((c >> 12) & 63); out[w + 2] = 128 | ((c >> 6) & 63); out[w + 3] = 128 | (c & 63)
return w + 4
}
# \uXXXX, p.i on the 'u': the code point, a surrogate pair joined (\ud83d\ude00 is one emoji); a
@ -671,18 +702,6 @@ function jp_hex4(p: JP, at: int) -> int {
}
return v
}
# one code point as a UTF-8 string
function jp_utf8(c: int) -> string {
let out = bytes(5)
var n = 0
if c < 128 { out[0] = c; n = 1 }
else if c < 2048 { out[0] = 192 | (c >> 6); out[1] = 128 | (c & 63); n = 2 }
else if c < 65536 { out[0] = 224 | (c >> 12); out[1] = 128 | ((c >> 6) & 63); out[2] = 128 | (c & 63); n = 3 }
else { out[0] = 240 | (c >> 18); out[1] = 128 | ((c >> 12) & 63); out[2] = 128 | ((c >> 6) & 63); out[3] = 128 | (c & 63); n = 4 }
out[n] = 0
let t: string = out
return t
}
# read a number; a '.' makes it a fixed node, otherwise an int node.
function jp_number(p: JP) -> Val {

View file

@ -64,7 +64,7 @@ A template error is NOT a `fail`: the request succeeded and the error is reporte
| `font <text dir>` | none. Reads `<dir>/font.json` (§5.3); `fail` if it has no `adv` list |
| `viewport <num w> <num h> [<num scale>]` | none. The frame's size in screen pixels, and screen pixels a design pixel; with no scale, `h / 1080` (the game's own rule). Default 1920 1080 |
| `locale <text po path>` | none. Every text a template shows is looked up in that gettext file as the game looks it up (ludic.i18n: exact, then patterns with holes, then sentence by sentence). `""` or `en` is English. The path is read through `file` overrides first |
| `load <text Name> <text xml> <text lss> [<text xml path> [<text lss path>]]` | events only (`err`). Registers component class `Name`, or replaces its template and styles in place if it exists. Paths default to `Name.xml` and `Name.lss`; they name the files in errors and rules, and an `@import` in the styles is read from beside the xml path. Every capitalised tag in the template naming a class not yet loaded gets an empty class (`<row/>`) of that name, filled in place when the studio loads it; the studio sends every class a story names before it `show`s the root |
| `load <text Name> <text xml> <text lss> [<text xml path> [<text lss path>]]` | events only (`err`). Registers component class `Name`, or replaces its template and styles in place if it exists. Paths default to `Name.xml` and `Name.lss`; they name the files in errors and rules, and an `@import` in the styles is resolved beside the lss path (§4.1). Every capitalised tag in the template naming a class not yet loaded gets an empty class (`<row/>`) of that name, filled in place when the studio loads it; the studio sends every class a story names before it `show`s the root |
| `model <text Name> <json object>` | none. The class's model: every key is a name its template reads. Props from the parent's tag and values the template `set`s are laid over it |
| `calls <text Name> <json object>` | none. How the class's functions answer (§7) |
| `show <text Name>` | none. The class a `frame` shows, as a whole screen; `fail` if it is not loaded |
@ -87,6 +87,15 @@ A template error is NOT a `fail`: the request succeeded and the error is reporte
Input takes effect in the next `frame`; a press's events are reported by that frame.
### 4.1 Paths
This is the rule v1 keeps. A later version will not change it silently.
- **The rule is for `@import` only.** A `locale` path and an `image` source are read exactly as written, relative to the host's working directory, and a `file` override for either uses that same text.
- **A leading `/` in an `@import`** means the game's root, which is the host's working directory. The slash is dropped: `@import "/src/ui/kit/theme.lss"` reads `src/ui/kit/theme.lss`. So an absolute filesystem path cannot be imported; start the host in the game's folder and write the path from there.
- **Any other `@import`** is joined to the directory of the sheet that imports it. For a class's own styles, that is its lss path as `load` gave it. For an imported sheet, it is that sheet's resolved path. So a sheet loaded as `src/ui/hud/Hud.lss` that imports `../kit/theme.lss` reads `src/ui/hud/../kit/theme.lss`, with no normalising of `..`. A class loaded under an absolute lss path imports under absolute paths.
- **A `file` override** applies to exactly the resolved path, byte for byte: `src/ui/kit/theme.lss` for the first example, and `src/ui/hud/../kit/theme.lss` for the second. Neither `/src/...` nor a normalised form matches.
## 5. The frame
```