450 lines
14 KiB
Go
450 lines
14 KiB
Go
// Command layerwire-gen diffs the canonical gotd schema (Layer 227, the bytes
|
||
// telesrv actually emits) against historical TDesktop api.tl layers (220..226)
|
||
// and classifies every per-constructor change as either MECHANICAL (a pure
|
||
// append-only delta that can be downgraded by dropping trailing/optional fields
|
||
// and masking flag bits) or STRUCTURAL (field reorder / reinterpretation that
|
||
// needs a hand-written transform).
|
||
//
|
||
// It is the generate-time half of the layer-compat design
|
||
// (docs/layer-compat-220-227-design.md). Run from the telesrv module root:
|
||
//
|
||
// go run ./internal/compat/layerwire/gen -report
|
||
//
|
||
// This first iteration only prints a report so the numbers can be validated
|
||
// against the design doc before any table is emitted.
|
||
package main
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"go/format"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/gotd/tl"
|
||
)
|
||
|
||
// canonicalLayer is the layer telesrv's gotd is pinned to.
|
||
const canonicalLayer = 227
|
||
|
||
// supportedFloor is the oldest client layer telesrv aims to serve.
|
||
const supportedFloor = 220
|
||
|
||
// spec is a single TL constructor or method with field-level metadata.
|
||
type spec struct {
|
||
qname string // qualified name, e.g. "messages.dialogs" or "message"
|
||
crc uint32
|
||
params []tl.Parameter
|
||
isFunc bool
|
||
}
|
||
|
||
// schema indexes one parsed .tl file by qualified name and by CRC.
|
||
type schema struct {
|
||
layer int
|
||
byName map[string]*spec
|
||
byCRC map[uint32]*spec
|
||
ordered []*spec
|
||
}
|
||
|
||
func qualify(d tl.Definition) string {
|
||
if len(d.Namespace) == 0 {
|
||
return d.Name
|
||
}
|
||
return strings.Join(d.Namespace, ".") + "." + d.Name
|
||
}
|
||
|
||
func load(path string) (*schema, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer f.Close()
|
||
parsed, err := tl.Parse(f)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||
}
|
||
s := &schema{
|
||
layer: parsed.Layer,
|
||
byName: make(map[string]*spec),
|
||
byCRC: make(map[uint32]*spec),
|
||
}
|
||
for i := range parsed.Definitions {
|
||
sd := parsed.Definitions[i]
|
||
d := sd.Definition
|
||
sp := &spec{
|
||
qname: qualify(d),
|
||
crc: d.ID,
|
||
params: d.Params,
|
||
isFunc: sd.Category == tl.CategoryFunction,
|
||
}
|
||
// Skip the implicit vector pseudo-definition if present.
|
||
if sp.qname == "vector" {
|
||
continue
|
||
}
|
||
s.byName[sp.qname] = sp
|
||
s.byCRC[sp.crc] = sp
|
||
s.ordered = append(s.ordered, sp)
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// classifyResult describes how a changed constructor downgrades from canonical
|
||
// (227) to a target layer.
|
||
type classifyResult struct {
|
||
mechanical bool
|
||
drops []string // canonical fields absent at the target layer
|
||
reason string // populated when !mechanical
|
||
}
|
||
|
||
// classifyDowngrade aligns the target params as a subsequence (by name) of the
|
||
// canonical params. Success ⇒ mechanical drop of the unmatched canonical fields.
|
||
// Any name mismatch, type change, or flag-condition change ⇒ structural.
|
||
func classifyDowngrade(from, to *spec) classifyResult {
|
||
var drops []string
|
||
i, j := 0, 0
|
||
fp, tp := from.params, to.params
|
||
for j < len(tp) {
|
||
// Advance over canonical fields until we reach the target field name.
|
||
for i < len(fp) && fp[i].Name != tp[j].Name {
|
||
drops = append(drops, fp[i].Name)
|
||
i++
|
||
}
|
||
if i == len(fp) {
|
||
return classifyResult{reason: fmt.Sprintf("target field %q not found in canonical (reorder/insert)", tp[j].Name)}
|
||
}
|
||
if reason := compatible(fp[i], tp[j]); reason != "" {
|
||
return classifyResult{reason: fmt.Sprintf("field %q: %s", tp[j].Name, reason)}
|
||
}
|
||
i++
|
||
j++
|
||
}
|
||
for ; i < len(fp); i++ {
|
||
drops = append(drops, fp[i].Name)
|
||
}
|
||
return classifyResult{mechanical: true, drops: drops}
|
||
}
|
||
|
||
// compatible reports "" if a kept field is wire-compatible between canonical and
|
||
// target, or a reason string otherwise.
|
||
func compatible(f, t tl.Parameter) string {
|
||
if f.Flags != t.Flags {
|
||
return "flags-int vs field mismatch"
|
||
}
|
||
if f.Flags {
|
||
// Both are `#` flag integers; the name must match because conditional
|
||
// fields reference it by name.
|
||
if f.Name != t.Name {
|
||
return fmt.Sprintf("flags int renamed %q->%q", t.Name, f.Name)
|
||
}
|
||
return ""
|
||
}
|
||
// Conditional-ness must match exactly (no flag-bit remap supported yet).
|
||
fc, tc := f.Flag != nil, t.Flag != nil
|
||
if fc != tc {
|
||
return "conditional-ness changed"
|
||
}
|
||
if fc {
|
||
if f.Flag.Name != t.Flag.Name || f.Flag.Index != t.Flag.Index {
|
||
return fmt.Sprintf("flag moved %s.%d->%s.%d", t.Flag.Name, t.Flag.Index, f.Flag.Name, f.Flag.Index)
|
||
}
|
||
}
|
||
if f.Type.String() != t.Type.String() {
|
||
return fmt.Sprintf("type changed %s->%s", t.Type.String(), f.Type.String())
|
||
}
|
||
return ""
|
||
}
|
||
|
||
type changed struct {
|
||
qname string
|
||
fromCRC, toCRC uint32
|
||
res classifyResult
|
||
}
|
||
|
||
// diff compares canonical (from) against a single target layer (to).
|
||
type diffResult struct {
|
||
layer int
|
||
changedTypes []changed
|
||
changedMethods []changed
|
||
newTypes []string // exist in canonical, absent at target
|
||
newMethods []string
|
||
removedTypes []string // exist at target, absent in canonical
|
||
}
|
||
|
||
func diff(from, to *schema) diffResult {
|
||
r := diffResult{layer: to.layer}
|
||
for _, sp := range from.ordered {
|
||
other, ok := to.byName[sp.qname]
|
||
if !ok {
|
||
if sp.isFunc {
|
||
r.newMethods = append(r.newMethods, sp.qname)
|
||
} else {
|
||
r.newTypes = append(r.newTypes, sp.qname)
|
||
}
|
||
continue
|
||
}
|
||
if other.crc == sp.crc {
|
||
continue
|
||
}
|
||
c := changed{qname: sp.qname, fromCRC: sp.crc, toCRC: other.crc, res: classifyDowngrade(sp, other)}
|
||
if sp.isFunc {
|
||
r.changedMethods = append(r.changedMethods, c)
|
||
} else {
|
||
r.changedTypes = append(r.changedTypes, c)
|
||
}
|
||
}
|
||
for _, sp := range to.ordered {
|
||
if _, ok := from.byName[sp.qname]; !ok {
|
||
r.removedTypes = append(r.removedTypes, sp.qname)
|
||
}
|
||
}
|
||
return r
|
||
}
|
||
|
||
func main() {
|
||
var (
|
||
schemaDir = flag.String("schema", "internal/compat/layerwire/_schema", "dir with layer-NNN.tl")
|
||
canonical = flag.String("canonical", "internal/compat/layerwire/schema/canonical-227.tl", "gotd canonical 227 schema")
|
||
emit = flag.String("emit", "", "write generated tables_gen.go to this path")
|
||
_ = flag.Bool("report", true, "print report")
|
||
)
|
||
flag.Parse()
|
||
|
||
canon, err := load(*canonical)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "load canonical:", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
if *emit != "" {
|
||
if err := emitTables(canon, *schemaDir, *emit); err != nil {
|
||
fmt.Fprintln(os.Stderr, "emit:", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("wrote %s\n", *emit)
|
||
return
|
||
}
|
||
fmt.Printf("canonical (gotd) layer=%d defs=%d\n", canon.layer, len(canon.ordered))
|
||
|
||
// Per-layer diff + union across the supported range.
|
||
unionChangedTypes := map[string]bool{}
|
||
unionChangedMethods := map[string]bool{}
|
||
unionNewTypes := map[string]bool{}
|
||
unionNewMethods := map[string]bool{}
|
||
structuralTypes := map[string]string{} // qname -> reason (worst case seen)
|
||
|
||
for L := supportedFloor; L < canonicalLayer; L++ {
|
||
path := filepath.Join(*schemaDir, fmt.Sprintf("layer-%d.tl", L))
|
||
tgt, err := load(path)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "load", path, ":", err)
|
||
os.Exit(1)
|
||
}
|
||
r := diff(canon, tgt)
|
||
mech, struc := 0, 0
|
||
for _, c := range r.changedTypes {
|
||
unionChangedTypes[c.qname] = true
|
||
if c.res.mechanical {
|
||
mech++
|
||
} else {
|
||
struc++
|
||
structuralTypes[c.qname] = c.res.reason
|
||
}
|
||
}
|
||
for _, c := range r.changedMethods {
|
||
unionChangedMethods[c.qname] = true
|
||
}
|
||
for _, n := range r.newTypes {
|
||
unionNewTypes[n] = true
|
||
}
|
||
for _, n := range r.newMethods {
|
||
unionNewMethods[n] = true
|
||
}
|
||
fmt.Printf("layer %d: defs=%d changedTypes=%d (mech=%d struc=%d) changedMethods=%d newTypes=%d newMethods=%d removed=%d\n",
|
||
L, len(tgt.ordered), len(r.changedTypes), mech, struc, len(r.changedMethods), len(r.newTypes), len(r.newMethods), len(r.removedTypes))
|
||
}
|
||
|
||
fmt.Printf("\n=== UNION %d..%d vs %d ===\n", supportedFloor, canonicalLayer-1, canonicalLayer)
|
||
fmt.Printf("changed types: %d\n", len(unionChangedTypes))
|
||
fmt.Printf("changed methods: %d\n", len(unionChangedMethods))
|
||
fmt.Printf("new types: %d\n", len(unionNewTypes))
|
||
fmt.Printf("new methods: %d\n", len(unionNewMethods))
|
||
fmt.Printf("structural types (need hand transform): %d\n", len(structuralTypes))
|
||
for _, q := range sortedKeys(structuralTypes) {
|
||
fmt.Printf(" - %s : %s\n", q, structuralTypes[q])
|
||
}
|
||
|
||
// Detailed 220-vs-227 drop table (matches design doc Appendix A).
|
||
fmt.Printf("\n=== 220 vs 227 changed-type drop table ===\n")
|
||
tgt220, _ := load(filepath.Join(*schemaDir, "layer-220.tl"))
|
||
r := diff(canon, tgt220)
|
||
sort.Slice(r.changedTypes, func(a, b int) bool { return r.changedTypes[a].qname < r.changedTypes[b].qname })
|
||
for _, c := range r.changedTypes {
|
||
tag := "MECH"
|
||
detail := "drop: " + strings.Join(c.res.drops, ", ")
|
||
if !c.res.mechanical {
|
||
tag = "STRUCT"
|
||
detail = c.res.reason
|
||
}
|
||
fmt.Printf(" [%-6s] %-34s %#08x->%#08x %s\n", tag, c.qname, c.toCRC, c.fromCRC, detail)
|
||
}
|
||
}
|
||
|
||
// emitTables writes the runtime downgrade tables (tables_gen.go) for every
|
||
// supported layer: per changed constructor a mechanical keep-list or a
|
||
// structural marker, plus the set of canonical CRCs absent at that layer.
|
||
func emitTables(canon *schema, schemaDir, outPath string) error {
|
||
var b strings.Builder
|
||
b.WriteString("// Code generated by ./internal/compat/layerwire/gen; DO NOT EDIT.\n")
|
||
b.WriteString("// Source: gotd canonical schema (Layer 227) diffed against TDesktop api.tl@N.\n\n")
|
||
b.WriteString("package layerwire\n\n")
|
||
b.WriteString("// generatedTables maps a supported client layer to its canonical(227)->layer\n")
|
||
b.WriteString("// downgrade table. See docs/layer-compat-220-227-design.md.\n")
|
||
b.WriteString("var generatedTables = map[int]layerRaw{\n")
|
||
|
||
// inbound 方法升级(扁平:老方法 CRC -> 227 CRC)。老 CRC 本身编码了格式,故无需 layer 维度。
|
||
// 仅收"升级安全"的方法:227 新增字段全为 flag-gated 条件字段(老客户端清零位=零字节,
|
||
// 其 body 本就是合法 227 body,换 4 字节 CRC 即可交给 227 handler)。
|
||
inboundUpgrades := map[uint32]uint32{} // oldCRC -> 227CRC
|
||
inboundUnsafe := map[string]string{} // qname -> reason
|
||
|
||
for L := supportedFloor; L < canonicalLayer; L++ {
|
||
tgt, err := load(filepath.Join(schemaDir, fmt.Sprintf("layer-%d.tl", L)))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
r := diff(canon, tgt)
|
||
for _, c := range r.changedMethods {
|
||
canonSpec := canon.byName[c.qname]
|
||
if reason := methodUpgradeSafe(canonSpec, c.res); reason == "" {
|
||
inboundUpgrades[c.toCRC] = c.fromCRC // client(old) -> canonical(227)
|
||
} else if _, done := inboundUpgrades[c.toCRC]; !done {
|
||
inboundUnsafe[c.qname] = reason
|
||
}
|
||
}
|
||
fmt.Fprintf(&b, "\t%d: {\n", L)
|
||
|
||
sort.Slice(r.changedTypes, func(i, j int) bool { return r.changedTypes[i].fromCRC < r.changedTypes[j].fromCRC })
|
||
b.WriteString("\t\trules: map[uint32]ruleRaw{\n")
|
||
for _, c := range r.changedTypes {
|
||
canonSpec := canon.byName[c.qname]
|
||
if c.res.mechanical {
|
||
dropSet := map[string]bool{}
|
||
for _, d := range c.res.drops {
|
||
dropSet[d] = true
|
||
}
|
||
var keep []string
|
||
for _, p := range canonSpec.params {
|
||
if !dropSet[p.Name] {
|
||
keep = append(keep, p.Name)
|
||
}
|
||
}
|
||
fmt.Fprintf(&b, "\t\t\t0x%08x: {target: 0x%08x, keep: %s}, // %s\n", c.fromCRC, c.toCRC, goStrSlice(keep), c.qname)
|
||
} else {
|
||
fmt.Fprintf(&b, "\t\t\t0x%08x: {target: 0x%08x, structural: %q}, // %s\n", c.fromCRC, c.toCRC, c.qname, c.res.reason)
|
||
}
|
||
}
|
||
b.WriteString("\t\t},\n")
|
||
|
||
var newCRC []uint32
|
||
for _, q := range r.newTypes {
|
||
if sp := canon.byName[q]; sp != nil {
|
||
newCRC = append(newCRC, sp.crc)
|
||
}
|
||
}
|
||
sort.Slice(newCRC, func(i, j int) bool { return newCRC[i] < newCRC[j] })
|
||
b.WriteString("\t\tnewTypes: []uint32{")
|
||
for i, c := range newCRC {
|
||
if i%6 == 0 {
|
||
b.WriteString("\n\t\t\t")
|
||
}
|
||
fmt.Fprintf(&b, "0x%08x, ", c)
|
||
}
|
||
if len(newCRC) > 0 {
|
||
b.WriteString("\n\t\t")
|
||
}
|
||
b.WriteString("},\n")
|
||
b.WriteString("\t},\n")
|
||
}
|
||
b.WriteString("}\n\n")
|
||
|
||
// Flat inbound method CRC upgrade table.
|
||
b.WriteString("// inboundMethodUpgrades maps an old client method constructor id to the\n")
|
||
b.WriteString("// canonical (227) id. Only upgrade-safe changes (all 227 additions flag-gated)\n")
|
||
b.WriteString("// are listed: rewriting the 4-byte id yields a valid 227 request body.\n")
|
||
if len(inboundUnsafe) > 0 {
|
||
b.WriteString("// NOT upgrade-safe as a pure id swap (declare a body transform in client-drift.tl when needed):\n")
|
||
for _, q := range sortedKeys(inboundUnsafe) {
|
||
fmt.Fprintf(&b, "// %s: %s\n", q, inboundUnsafe[q])
|
||
}
|
||
}
|
||
b.WriteString("var inboundMethodUpgrades = map[uint32]uint32{\n")
|
||
oldCRCs := make([]uint32, 0, len(inboundUpgrades))
|
||
for old := range inboundUpgrades {
|
||
oldCRCs = append(oldCRCs, old)
|
||
}
|
||
sort.Slice(oldCRCs, func(i, j int) bool { return oldCRCs[i] < oldCRCs[j] })
|
||
for _, old := range oldCRCs {
|
||
fmt.Fprintf(&b, "\t0x%08x: 0x%08x, // %s\n", old, inboundUpgrades[old], canon.byCRC[inboundUpgrades[old]].qname)
|
||
}
|
||
b.WriteString("}\n")
|
||
|
||
formatted, err := format.Source([]byte(b.String()))
|
||
if err != nil {
|
||
_ = os.WriteFile(outPath, []byte(b.String()), 0o644)
|
||
return fmt.Errorf("gofmt: %w", err)
|
||
}
|
||
return os.WriteFile(outPath, formatted, 0o644)
|
||
}
|
||
|
||
// methodUpgradeSafe reports "" if a layer-N request body for a changed method
|
||
// is also a valid 227 body after only swapping the constructor id — i.e. the
|
||
// downgrade is mechanical and every 227-only field is flag-gated (a conditional
|
||
// field the old client leaves clear ⇒ zero wire bytes). A 227-only non-conditional
|
||
// field or an inserted flags integer breaks the byte alignment ⇒ unsafe.
|
||
func methodUpgradeSafe(canonSpec *spec, res classifyResult) string {
|
||
if !res.mechanical {
|
||
return res.reason
|
||
}
|
||
byName := map[string]tl.Parameter{}
|
||
for _, p := range canonSpec.params {
|
||
byName[p.Name] = p
|
||
}
|
||
for _, d := range res.drops {
|
||
p, ok := byName[d]
|
||
if !ok {
|
||
return fmt.Sprintf("dropped field %q not in canonical", d)
|
||
}
|
||
if p.Flags {
|
||
return fmt.Sprintf("227 inserts flags integer %q", d)
|
||
}
|
||
if p.Flag == nil {
|
||
return fmt.Sprintf("227-only field %q is non-conditional", d)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func goStrSlice(ss []string) string {
|
||
var b strings.Builder
|
||
b.WriteString("[]string{")
|
||
for i, s := range ss {
|
||
if i > 0 {
|
||
b.WriteString(", ")
|
||
}
|
||
b.WriteString(strconv.Quote(s))
|
||
}
|
||
b.WriteString("}")
|
||
return b.String()
|
||
}
|
||
|
||
func sortedKeys[V any](m map[string]V) []string {
|
||
ks := make([]string, 0, len(m))
|
||
for k := range m {
|
||
ks = append(ks, k)
|
||
}
|
||
sort.Strings(ks)
|
||
return ks
|
||
}
|