Groundwork for replacing a Telesis pinstamp with a SIC Marking e10 while
keeping the existing eDNC/MarkTelesis operator workflow. MarkTelesis has a
hardcoded marker-type enum and eDNC is serial-only, so the plan is a
translator that impersonates a Telesis controller on a COM port. These are
the pieces that translator will be built from, all verified against a real
e10 (a P63, firmware "E10 P63 (20140924)").
scripts/e10-client.py
e10-V6 protocol client. Marking path and file transfer both use the BINARY
protocol: the text protocol uses space as its delimiter, so it cannot carry
a field cleared to a single space nor the space runs in real marked text,
and it has no checksum. Includes fault decoding against the manual's fig 1,
and a guard that refuses to overflow a variable's declared buffer.
scripts/make-e10-pattern.py
Builds marking files with DECLARED variables. The controller's own protocol
cannot create one: NEWFILE/INSERTTEXTLINE/SAVEFILE writes the reference but
leaves the variable count at zero, and SETVAR then fails. Template-based,
copying through byte-for-byte everything the format notes do not explain.
scripts/simulate-ednc.py
Parses a part program, substitutes tokens and emits the byte-exact frames
MarkTelesis would send, then translates them for the e10 and can drive one.
The wire format came from disassembling MarkTelesis.exe rather than from a
capture: frame builder at 0x401cf0, and the type letters at their call
sites. Note there is no station ID, unlike the Backup470 dialect.
scripts/fake-telesis.py
Answers as a Telesis controller so MarkTelesis will hold a conversation
long enough to be recorded, with switches to force NAK and timeout paths.
reference/e10/
52-file snapshot off the bench controller, used as the corpus for reversing
the MARK_V5.0 format and for testing without hardware. ConfigCtrl is
excluded and gitignored - it stores the supervisor password in cleartext.
GE part programs are excluded for the same reason.
Three hardware findings worth knowing:
- The controller TRUNCATES SILENTLY to a variable's declared buffer size
while SETVAR still answers ok, and GETVAR echoes the full value back so
it cannot detect this. Guarded client-side against the size in the file.
- It refuses to mark when a referenced variable is unset, which makes an
unset field fail loudly rather than marking the previous part's data.
- Setting variables does not modify the stored file, so backups will not
churn on every marked part.
280 lines
11 KiB
Python
280 lines
11 KiB
Python
#!/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()
|