Files
pxe-server/scripts/make-e10-pattern.py
cproudlock eb4d067f12 feat(e10): SIC e10 part marker client, pattern generator and eDNC simulator
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.
2026-08-27 12:57:20 -04:00

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()