CultLib/scripts/gen-unicode-messageid-tables.py
soul f378d4fdb0
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
tests first: duplicate messageId, ill-formed id, scoped UTF-8 strictness, every table range pinned (failing)
Failing before the fix: C# 1 (duplicate messageId, second is U+200B), TypeScript 1 (a + lone surrogate),
Kotlin 1 (a document payload string with a lone surrogate no longer decodes). Python and Rust already pass
the new vectors. The range probes read the generated write-admitted-message-id-ranges.json and pass on the
correct tables; they exist to fail when any range is deleted or narrowed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:11:15 +00:00

119 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Generate the Unicode 16.0 tables of the write_admitted messageId rule for the five CultNet runtimes.
The rule (contracts/cultnet/interop/write-receipt.md): a messageId is valid iff it holds a letter (Lu Ll Lt Lm Lo) or a number
(Nd Nl No) and no Default_Ignorable_Code_Point, all as of Unicode 16.0. The tables are embedded in each runtime so that no
platform's own Unicode version can change a result. Bumping the version is one coordinated change: run this script on the new
UCD files, commit the five regenerated files and the vectors that pin the new version, together.
gen-unicode-messageid-tables.py DerivedGeneralCategory.txt DerivedCoreProperties.txt [--check]
The inputs are https://www.unicode.org/Public/16.0.0/ucd/extracted/DerivedGeneralCategory.txt and
https://www.unicode.org/Public/16.0.0/ucd/DerivedCoreProperties.txt. With --check nothing is written; the exit status is 1 if
any generated file differs from what these inputs produce (a stale or hand-edited table). Standard library only.
"""
import hashlib
import json
import re
import sys
from pathlib import Path
VERSION = "16.0.0"
ROOT = Path(__file__).resolve().parent.parent
LETTER_OR_NUMBER_CATEGORIES = {"Lu", "Ll", "Lt", "Lm", "Lo", "Nd", "Nl", "No"}
TARGETS = {
"ts": ROOT / "packages/cultnet-ts/src/unicode-messageid-tables.ts",
"py": ROOT / "packages/cultnet-py/src/cultnet_py/unicode_messageid_tables.py",
"kt": ROOT / "packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/UnicodeMessageIdTables.kt",
"cs": ROOT / "src/GameCult.Networking/CultNetUnicodeMessageIdTables.cs",
"rs": ROOT / "packages/cultnet-rs/src/unicode_messageid_tables.rs",
}
# The independent copy the runtimes' tests probe (first, last, and the code point either side of every range), so that deleting
# or narrowing any range of any embedded table fails a test.
RANGES_JSON = ROOT / "contracts/cultnet/interop/write-admitted-message-id-ranges.json"
def parse(path, wanted):
"""Ranges (low, high) of the lines `LOW[..HIGH] ; VALUE`, VALUE accepted by [wanted], merged and sorted."""
points = []
for line in Path(path).read_text(encoding="utf-8").splitlines():
m = re.match(r"([0-9A-F]+)(?:\.\.([0-9A-F]+))?\s*;\s*(\S+)", line)
if m and wanted(m.group(3)):
points.append((int(m.group(1), 16), int(m.group(2) or m.group(1), 16)))
points.sort()
merged = []
for low, high in points:
if merged and low <= merged[-1][1] + 1:
merged[-1] = (merged[-1][0], max(merged[-1][1], high))
else:
merged.append((low, high))
return merged
def flat(ranges):
return [value for pair in ranges for value in pair]
def wrapped(items, indent, render, width=118):
lines, current = [], indent
for item in items:
text = render(item) + ", "
if len(current) + len(text) > width:
lines.append(current.rstrip())
current = indent
current += text
lines.append(current.rstrip())
return "\n".join(lines)
def emit(target, letters, ignorable, header):
num = lambda v: f"0x{v:X}"
if target == "ts":
body = lambda name, r: f"export const {name}: readonly number[] = [\n{wrapped(flat(r), ' ', num)}\n];\n"
return f"// {header}\n\n// [low, high] pairs, sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
if target == "py":
body = lambda name, r: f"{name}: tuple[int, ...] = (\n{wrapped(flat(r), ' ', num)}\n)\n"
return f"# {header}\n\n# (low, high) pairs, flattened and sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
if target == "kt":
body = lambda name, r: f"internal val {name}: IntArray = intArrayOf(\n{wrapped(flat(r), ' ', num)}\n)\n"
return f"// {header}\n\npackage org.gamecult.cultmesh\n\n// (low, high) pairs, flattened and sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
if target == "cs":
body = lambda name, r: f" public static readonly int[] {name} =\n {{\n{wrapped(flat(r), ' ', num)}\n }};\n"
return f"// {header}\n\nnamespace GameCult.Networking\n{{\n internal static class CultNetUnicodeMessageIdTables\n {{\n // (low, high) pairs, flattened and sorted.\n{body('LetterOrNumber', letters)}\n{body('DefaultIgnorable', ignorable)} }}\n}}\n"
body = lambda name, r: f"pub const {name}: &[(u32, u32)] = &[\n{wrapped(r, ' ', lambda p: f'({num(p[0])}, {num(p[1])})')}\n];\n"
return f"// {header}\n\n// (low, high) pairs, sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
def main():
args = [a for a in sys.argv[1:] if a != "--check"]
check = "--check" in sys.argv[1:]
if len(args) != 2:
sys.exit(__doc__)
gc, dcp = args
for path, marker in ((gc, f"DerivedGeneralCategory-{VERSION}"), (dcp, f"DerivedCoreProperties-{VERSION}")):
if marker not in Path(path).read_text(encoding="utf-8").splitlines()[0]:
sys.exit(f"{path} is not {marker}.txt")
letters = parse(gc, lambda v: v in LETTER_OR_NUMBER_CATEGORIES)
ignorable = parse(dcp, lambda v: v == "Default_Ignorable_Code_Point")
digest = hashlib.sha256(b"".join(Path(p).read_bytes() for p in (gc, dcp))).hexdigest()[:16]
header = f"generated, Unicode {VERSION}, by scripts/gen-unicode-messageid-tables.py (inputs sha256 {digest}); do not edit"
stale = []
texts = {path: emit(target, letters, ignorable, header) for target, path in TARGETS.items()}
texts[RANGES_JSON] = json.dumps(
{"generated": header, "unicodeVersion": VERSION, "letterOrNumber": [list(r) for r in letters], "defaultIgnorable": [list(r) for r in ignorable]},
separators=(",", ":"),
) + "\n"
for path, text in texts.items():
if check:
if not path.exists() or path.read_text(encoding="utf-8") != text:
stale.append(str(path.relative_to(ROOT)))
else:
path.write_text(text, encoding="utf-8")
if stale:
sys.exit("stale or hand-edited: " + ", ".join(stale))
print(f"{'checked' if check else 'wrote'} {len(texts)} files: {len(letters)} letter-or-number ranges, {len(ignorable)} default-ignorable ranges")
if __name__ == "__main__":
main()