From 3e313f822326d50a7aa91e4e527329a31b5f8572 Mon Sep 17 00:00:00 2001 From: "arda.aydin@ontrol.com.tr" Date: Wed, 12 Aug 2026 20:49:31 +0300 Subject: [PATCH] First commit tekrar Bu dosyalar Claude uzerinden olusturuldu --- README.md | 73 ++++++++++++++++++++++++ sab.py | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ verify.py | 53 +++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 README.md create mode 100644 sab.py create mode 100644 verify.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..6794ce9 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# PySedonac + +Pure-Python `.sax` -> `.sab` encoder. No Java, no JRE. + +Ported from the Sedona 1.2 runtime library — `sedona/src/sedona/src/sedona/offline/OfflineApp.java` +and friends, **not** from `sedonac/` (that's the language compiler; app file conversion lives in the +runtime lib). + +Verified byte-identical to `sedonac.exe` on a 189-component / 217-link app across 18 kits. + +## Usage + + python sab.py + +`sedona_home` is the directory holding `manifests/` — kit manifests are required, since slot ids and +types are resolved from them. + +## Verify + + python verify.py [sedona_home] + +Compares our output against the checked-in `test_normal_sedonac.sab`. Pass a sedona home containing +`bin/sedonac.exe` and call `check(sax)` with no reference to diff against a live sedonac run instead. + +## Format + +Big-endian. **No padding, no alignment** — `Buf.bigEndian = true`, `checkAlignment = false`. The +`align()`/`pad()` helpers exist on `Buf` but the app encoder never calls them. + + "sapp" (i4 0x73617070) | version (i4 0x0003) + schema: u1 kitCount, then per kit: cstr name, i4 checksum + u2 maxId + components (ascending id, NOT tree order), each: + u2 id | u1 kitId | u1 typeId + cstr name | u2 parentId | u2 firstChildId | u2 nextSiblingId (0xffff = none) + config prop values in slot-id order, bare - no names, no ids + u1 ';' + u2 0xffff + links: u2 fromComp | u1 fromSlot | u2 toComp | u1 toSlot + u2 0xffff + u1 '.' + +Values: `bool` -> u1 (0/1, **2 = null**) | `byte` -> u1 | `short` -> u2 | `int` -> i4 | `long` -> i8 | +`float` -> f4 | `double` -> f8 | `Buf` -> u2 len + raw bytes | `asStr` -> u2 (len+1) + ASCII + NUL. + +Slot flags (`SlotManifest.flagsToString`): `a` = action, `c` = config, `s` = asStr, `o` = operator. + +## Three things that will bite you + +**Kit order is normative.** `sys` at index 0, everything else alphabetical (`Schema.sortKits`). Kit id +is the position in that sorted list, and every component record references it. Document order of the +`` block in the SAX is irrelevant. + +**Slot ids come from flattening.** `Type.resolveSlots` inherits base slots then appends declared ones; +the resulting index is the id written into links. An **action override** reuses the inherited id rather +than appending (`Type.addSlot`) — get that wrong and every subsequent slot id shifts. + +**Missing prop is not null.** When a `` is absent the value is the manifest's `default` +attribute, or failing that `Value.defaultForType` -> **zero**. `null` (NaN, `0x7fc00000` for float) +only when the SAX literally says `val="null"`. This was the single bug between "same length" and +"byte identical". + +## Not covered + +Only what `test_normal.sax` exercises is proven. Untested: action overrides, `Buf`-typed props +(base64 path is written but unexercised), non-ASCII strings (Sedona `Str` is ASCII-only and will +raise), and apps whose component ids exceed the 256-entry lookup table. + +## Alternative + +If a JRE is present, `sedona/bin/sedonac.exe ` converts either direction off the file +extension, and additionally validates the app (schema resolution, RAM/FLASH sizing) in a way this +encoder does not. diff --git a/sab.py b/sab.py new file mode 100644 index 0000000..24cb704 --- /dev/null +++ b/sab.py @@ -0,0 +1,166 @@ +"""Minimal pure-Python SAX -> SAB encoder, ported from sedona.offline.OfflineApp.""" +import os, struct, sys, base64 +import xml.etree.ElementTree as ET + +class Buf: + def __init__(self): self.b = bytearray() + def u1(self, v): self.b.append(v & 0xFF) + def u2(self, v): self.b += struct.pack(">H", v & 0xFFFF) + def i4(self, v): self.b += struct.pack(">i", v if -2**31 <= v < 2**31 else v - 2**32) + def i8(self, v): self.b += struct.pack(">q", v) + def f4(self, v): self.b += struct.pack(">f", v) + def f8(self, v): self.b += struct.pack(">d", v) + def str_(self, s): self.b += s.encode("ascii"); self.u1(0) + +# ---------- schema ---------- +def load_schema(sax_root, home): + parts = [] + for k in sax_root.find("schema").findall("kit"): + parts.append((k.get("name"), k.get("checksum"))) + # sortKits: sys first, rest alphabetical + parts.sort(key=lambda p: ("" if p[0] == "sys" else "\x01" + p[0])) + + kits = [] # [(name, checksum, {typeName: typeElem})] + types_by_q = {} + for kit_id, (name, cks) in enumerate(parts): + d = os.path.join(home, "manifests", name) + f = os.path.join(d, "%s-%s.xml" % (name, cks)) if cks else None + if not f or not os.path.exists(f): + cands = sorted(x for x in os.listdir(d) if x.startswith(name + "-") and x.endswith(".xml")) + f = os.path.join(d, cands[-1]) + mroot = ET.parse(f).getroot() + tlist = sorted(mroot.findall("type"), key=lambda t: int(t.get("id"))) + for t in tlist: + types_by_q["%s::%s" % (name, t.get("name"))] = t + kits.append((name, int(mroot.get("checksum"), 16), tlist)) + return kits, types_by_q + +def resolve_slots(qname, types_by_q, cache): + """Flatten base chain -> [(name, type, flags, default)] indexed by slot id.""" + if qname in cache: return cache[qname] + t = types_by_q[qname] + base = t.get("base") + out = list(resolve_slots(base, types_by_q, cache)) if base else [] + byname = {s[0]: i for i, s in enumerate(out)} + for s in sorted(t.findall("slot"), key=lambda s: int(s.get("id"))): + rec = (s.get("name"), s.get("type"), s.get("flags", ""), s.get("default")) + if rec[0] in byname: # action override keeps the inherited id + out[byname[rec[0]]] = rec + else: + byname[rec[0]] = len(out); out.append(rec) + cache[qname] = out + return out + +# ---------- values ---------- +def _int(raw): # Integer.decode: supports 0x / # / leading 0 octal + if raw in (None, "", "null"): return 0 + return int(raw, 0) if raw.lower().startswith(("0x", "-0x")) else int(raw) + +def _flt(raw): + if raw == "null": return float("nan") # explicit null only + if raw in (None, ""): return 0.0 # Value.defaultForType -> ZERO + return float(raw) + +def encode_value(out, vtype, flags, raw): + if "s" in flags: # asStr Buf + s = raw or "" + out.u2(len(s) + 1) + out.b += s.encode("ascii"); out.u1(0) + elif vtype == "bool": + out.u1(2 if raw == "null" else (1 if raw in ("true", "1") else 0)) + elif vtype == "byte": out.u1(_int(raw)) + elif vtype == "short": out.u2(_int(raw)) + elif vtype == "int": out.i4(_int(raw)) + elif vtype == "long": out.i8(_int(raw)) + elif vtype == "float": out.f4(_flt(raw)) + elif vtype == "double": out.f8(_flt(raw)) + elif vtype == "sys::Buf" or vtype == "buf": + data = base64.b64decode(raw) if raw else b"" + out.u2(len(data)); out.b += data + else: + raise Exception("unhandled slot type " + str(vtype)) + +# ---------- main ---------- +def encode(sax_path, home, sab_path): + root = ET.parse(sax_path).getroot() + kits, types_by_q = load_schema(root, home) + kit_id = {k[0]: i for i, k in enumerate(kits)} + type_id = {} + for ki, (kname, _, tlist) in enumerate(kits): + for t in tlist: + type_id["%s::%s" % (kname, t.get("name"))] = (ki, int(t.get("id"))) + cache = {} + + # walk tree -> comps with (elem, type, parent, kids) + comps = [] + def walk(elem, qtype, parent): + rec = {"elem": elem, "type": qtype, "parent": parent, "kids": [], + "id": int(elem.get("id")) if elem.get("id") else None} + comps.append(rec) + if parent is not None: parent["kids"].append(rec) + for c in elem.findall("comp"): + walk(c, c.get("type"), rec) + return rec + + app_elem = root.find("app") + app = walk(app_elem, "sys::App", None) + app["id"] = 0 + + # assign missing ids + used = {c["id"] for c in comps if c["id"] is not None} + nxt = 1 + for c in comps: + if c["id"] is None: + while nxt in used: nxt += 1 + c["id"] = nxt; used.add(nxt) + by_id = {c["id"]: c for c in comps} + + out = Buf() + out.i4(0x73617070) # "sapp" + out.i4(0x0003) # version 0.3 + out.u1(len(kits)) # schema + for name, cks, _ in kits: + out.str_(name); out.i4(cks) + out.u2(max(by_id)) # maxId + + for cid in sorted(by_id): # components, ascending id + c = by_id[cid] + slots = resolve_slots(c["type"], types_by_q, cache) + ki, ti = type_id[c["type"]] + out.u2(cid); out.u1(ki); out.u1(ti) + out.str_(c["elem"].get("name", "app") if cid else "app") + out.u2(c["parent"]["id"] if c["parent"] else 0xFFFF) + out.u2(c["kids"][0]["id"] if c["kids"] else 0xFFFF) + sib = 0xFFFF + if c["parent"]: + k = c["parent"]["kids"]; i = k.index(c) + if i + 1 < len(k): sib = k[i + 1]["id"] + out.u2(sib) + props = {p.get("name"): p.get("val") for p in c["elem"].findall("prop")} + for name, vtype, flags, dflt in slots: + if "a" in flags or "c" not in flags: continue # config props only + encode_value(out, vtype, flags, props.get(name, dflt)) + out.u1(ord(';')) + out.u2(0xFFFF) + + links = root.find("links") + if links is not None: + for ln in links.findall("link"): + def ref(s): + path, slot = s.rsplit(".", 1) + cur = app + for part in [p for p in path.split("/") if p]: + cur = next(k for k in cur["kids"] if k["elem"].get("name") == part) + sl = resolve_slots(cur["type"], types_by_q, cache) + return cur["id"], [x[0] for x in sl].index(slot) + fc, fs = ref(ln.get("from")); tc, ts = ref(ln.get("to")) + out.u2(fc); out.u1(fs); out.u2(tc); out.u1(ts) + out.u2(0xFFFF) + out.u1(ord('.')) + + open(sab_path, "wb").write(bytes(out.b)) + return len(out.b) + +if __name__ == "__main__": + n = encode(sys.argv[1], sys.argv[2], sys.argv[3]) + print("wrote", n, "bytes") diff --git a/verify.py b/verify.py new file mode 100644 index 0000000..762f2a1 --- /dev/null +++ b/verify.py @@ -0,0 +1,53 @@ +"""Regression check: our SAB output must be byte-identical to sedonac.exe's. + +Usage: python verify.py [sedona_home] +""" +import os, sys, subprocess, tempfile, shutil +import sab + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOME = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(HERE), "sedona") + + +def check(sax, reference=None): + """Encode `sax` with sab.py; compare against `reference` or a fresh sedonac run.""" + tmp = tempfile.mkdtemp() + try: + if reference is None: + work = os.path.join(tmp, os.path.basename(sax)) + shutil.copy(sax, work) + exe = os.path.join(HOME, "bin", "sedonac.exe") + r = subprocess.run([exe, work], capture_output=True, text=True) + if r.returncode != 0: + print(" sedonac failed:\n" + r.stdout + r.stderr) + return False + reference = os.path.splitext(work)[0] + ".sab" + + mine = os.path.join(tmp, "mine.sab") + sab.encode(sax, HOME, mine) + + a = open(reference, "rb").read() + b = open(mine, "rb").read() + name = os.path.basename(sax) + if a == b: + print(" PASS %-28s %d bytes identical" % (name, len(a))) + return True + + diffs = [i for i in range(min(len(a), len(b))) if a[i] != b[i]] + print(" FAIL %-28s sedonac=%d ours=%d, %d byte diffs" % (name, len(a), len(b), len(diffs))) + if diffs: + i = diffs[0] + lo, hi = max(0, i - 12), i + 12 + print(" first diff at offset %d" % i) + print(" sedonac %s" % a[lo:hi].hex()) + print(" ours %s" % b[lo:hi].hex()) + return False + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + print("sedona home: %s" % HOME) + ok = check(os.path.join(HERE, "test_normal.sax"), + os.path.join(HERE, "test_normal_sedonac.sab")) + sys.exit(0 if ok else 1)