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
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>
119 lines
6.2 KiB
Python
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()
|