#!/usr/bin/env python3 """Build SIC e10 marking files with DECLARED variables. WHY THIS EXISTS The e10's own protocol cannot create a variable. You can build a marking file over the wire with NEWFILE / INSERTTEXTLINE / SAVEFILE, and you can put "#(V, V01)" in a text line, but that is only a REFERENCE. The variable has to be DECLARED, and there is no INSERTVAR command. Proven the hard way: a file built entirely over the protocol came back with a variable count of zero, and SETVAR answered "variable not found". Global variables are numbered 1-10, so #(V, NAME) cannot reach one either. That left authoring dependent on SIC's GUI, which would have made migrating a fleet of Telesis patterns a hand operation. This does it from a template instead. THE FILE FORMAT, reversed from 28 real files plus one we built over the wire header 0x00 "MARK_V5.0\\0" 0x0A variable count <- the byte that matters. zero here means SETVAR will always fail. 0x0B record count 0x0C two bytes, purpose unknown, carried through unchanged 0x0E offset table: 16-bit big-endian file offsets, one per record. The number of entries is implied - the first entry points at the first record, so entries = (first_offset - 0x0E) / 2. variable declaration record [type][len-6][class][00][name, NUL-padded] type 0x02 and 0x04 both seen; class 0x50 ('P') and 0x40 ('@') both seen. Carried through unchanged except for the name, which is rewritten inside its fixed-width padded field so the record length never moves. text line record [X(2)][Y(2)][Z(2)][W(2)][H(2)][2 bytes][textlen+13 (2)][2 bytes] [0x83][font name, 12 bytes NUL-padded][text] All coordinates are big-endian tenths of a mm. The field at offset 12 is len(text) + 13 in every file checked, which is what makes rewriting the text safe. trailer records Short fixed records at the end. Carried through untouched. WHAT THIS DOES Takes a known-good file as a template, rewrites the variable names and the text lines, then rebuilds the offset table so the records still line up. Everything it does not understand is copied through byte for byte, which is the only honest way to edit a format nobody has documented. """ import argparse import os import sys MAGIC = b"MARK_V5.0\x00" TEXT_HEADER_LEN = 29 # geometry + marker byte + 12-byte font field TEXT_LEN_OFFSET = 12 # where len(text)+13 lives inside a text record TEXT_LEN_BIAS = 13 FONT_OFFSET = 17 FONT_FIELD = 12 NAME_OFFSET = 4 # where a variable's name starts in its record def be16(data, index): return (data[index] << 8) | data[index + 1] def pack_be16(value): return bytes([(value >> 8) & 0xFF, value & 0xFF]) class Pattern: """A parsed MARK_V5.0 file: a header, a list of records, nothing hidden.""" def __init__(self, raw): if not raw.startswith(MAGIC): raise ValueError("not a MARK_V5.0 file") self.raw = raw self.varcount = raw[0x0A] self.reccount = raw[0x0B] self.unknown = raw[0x0C:0x0E] first = be16(raw, 0x0E) entries = (first - 0x0E) // 2 if entries < 1: raise ValueError("implausible offset table") offsets = [be16(raw, 0x0E + 2 * i) for i in range(entries)] self.records = [] for index, start in enumerate(offsets): nxt = offsets[index + 1] if index + 1 < len(offsets) else len(raw) if nxt <= start: nxt = len(raw) self.records.append(bytearray(raw[start:nxt])) # ------------------------------------------------------------ inspection def is_variable(self, record): """A declaration record, identified by its own length arithmetic. Do NOT match on byte 0 being 0x02 or 0x04 - that is the name LENGTH, which changes the moment you rename anything, and matching on it makes a renamed variable invisible to the next edit. """ return (len(record) >= NAME_OFFSET + 1 and record[2] in (0x40, 0x50) and record[3] == 0x00 and len(record) == NAME_OFFSET + record[0] + record[1]) def is_text(self, record): return (len(record) > FONT_OFFSET and record[FONT_OFFSET - 1] == 0x83) def variable_names(self): out = [] for rec in self.records: if self.is_variable(rec): out.append(bytes(rec[NAME_OFFSET:]).split(b"\x00")[0] .decode("ascii", "replace")) return out def variable_sizes(self): """Value buffer size per variable - the silent truncation limit.""" return [r[1] for r in self.records if self.is_variable(r)] def texts(self): out = [] for rec in self.records: if self.is_text(rec): out.append(bytes(rec[TEXT_HEADER_LEN:]) .decode("ascii", "replace")) return out # -------------------------------------------------------------- editing def rename_variable(self, index, new_name, value_size=None): """Rewrite a declaration's name. THE RECORD LAYOUT, and the two length bytes both matter: [name_len][value_size][class][00][name][value buffer] SN 02 0a 50 00 "SN" 16 = 4 + 2 + 10 PN 02 0e 50 00 "PN" 20 = 4 + 2 + 14 OF 02 0f 40 00 "OF" 21 = 4 + 2 + 15 TEMP 04 02 40 00 "TEMP" 10 = 4 + 4 + 2 Byte 0 is the length of the NAME. Byte 1 is the size of the VALUE buffer that follows it, which is the longest string SETVAR can put in this variable. Both were found the hard way. Writing a three-character name while byte 0 still said 2 produced a file that pushed cleanly, read back byte-identical and loaded without complaint - and then SETVAR answered "variable not found", because the controller had parsed a name of the wrong length. A silent, entirely plausible-looking failure. """ found = [i for i, r in enumerate(self.records) if self.is_variable(r)] if index >= len(found): raise ValueError("no variable %d in this template" % index) position = found[index] rec = self.records[position] encoded = new_name.encode("ascii") if not 1 <= len(encoded) <= 255: raise ValueError("implausible variable name %r" % new_name) size = rec[1] if value_size is None else value_size rebuilt = bytearray(rec[:NAME_OFFSET]) rebuilt[0] = len(encoded) rebuilt[1] = size rebuilt += encoded rebuilt += b"\x00" * size self.records[position] = rebuilt def set_text(self, index, text): """Replace a text line's content and fix its internal length field.""" found = [i for i, r in enumerate(self.records) if self.is_text(r)] if index >= len(found): raise ValueError("no text line %d in this template" % index) rec = self.records[found[index]] encoded = text.encode("ascii") rec[TEXT_HEADER_LEN:] = encoded rec[TEXT_LEN_OFFSET:TEXT_LEN_OFFSET + 2] = pack_be16( len(encoded) + TEXT_LEN_BIAS) def set_position(self, index, x=None, y=None): """Move a text line. Coordinates are tenths of a mm.""" found = [i for i, r in enumerate(self.records) if self.is_text(r)] rec = self.records[found[index]] if x is not None: rec[0:2] = pack_be16(x) if y is not None: rec[2:4] = pack_be16(y) # ------------------------------------------------------------- assembly def build(self): """Reassemble, recomputing every offset from the record lengths.""" entries = len(self.records) table_start = 0x0E body_start = table_start + 2 * entries offsets, cursor = [], body_start for rec in self.records: offsets.append(cursor) cursor += len(rec) out = bytearray(MAGIC) out.append(self.varcount) out.append(self.reccount) out += self.unknown for off in offsets: out += pack_be16(off) for rec in self.records: out += rec return bytes(out) def describe(pattern, label): print("%s" % label) sizes = pattern.variable_sizes() print(" variables declared : %d %s" % (pattern.varcount, ", ".join("%s(max %d)" % (n, s) for n, s in zip(pattern.variable_names(), sizes)) or "-")) print(" records : %d" % len(pattern.records)) for text in pattern.texts(): print(" text line : %r" % text) def main(): parser = argparse.ArgumentParser( description="Build an e10 marking file with declared variables.") parser.add_argument("--template", required=True, help="a known-good marking file with at least as many" " variables as you need. NEW.marking has 2.") parser.add_argument("--var", action="append", default=[], help="new name for the next variable. repeatable.") parser.add_argument("--var-size", type=int, default=127, help="value buffer size for each variable, 1-127." " THE CONTROLLER TRUNCATES SILENTLY to this and" " still answers SETVAR ok, so the default is the" " protocol maximum. Only lower it deliberately.") parser.add_argument("--line", action="append", default=[], help="text for the next line. repeatable." " Use #(V, NAME) to reference a variable.") parser.add_argument("-o", "--out", help="write the file here") parser.add_argument("--show", action="store_true", help="just describe the template and exit") args = parser.parse_args() pattern = Pattern(open(args.template, "rb").read()) describe(pattern, "TEMPLATE %s" % args.template) if args.show: return if not 1 <= args.var_size <= 127: sys.exit("--var-size must be 1..127 (FILE SET VAR carries 1-127 bytes)") for index, name in enumerate(args.var): pattern.rename_variable(index, name, value_size=args.var_size) for index, text in enumerate(args.line): pattern.set_text(index, text) built = Pattern(pattern.build()) print() describe(built, "BUILT") if args.out: with open(args.out, "wb") as handle: handle.write(pattern.build()) print() print("wrote %s (%d bytes)" % (args.out, len(pattern.build()))) if __name__ == "__main__": main()