Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219"""Strict, independent decoder for HEXWORK's emitted VT subset (stdlib only).
Colors are None (terminal default), indexed integers, or RGB tuples. A cell is(glyph, foreground, background, bold). Unsupported controls deliberately fail.This is not a general terminal emulator: wide/combining glyphs fail explicitly."""import codecsimport htmlimport jsonimport osimport pathlibimport unicodedata
class VT: def __init__(self, width=80, height=24): self.width, self.height = width, height self.fg = self.bg = None self.bold = False self.x = self.y = 0 self.wrap = True self.pending_wrap = False self.alternate = False self.saved = None self.visible = True self.decoder = codecs.getincrementaldecoder('utf-8')('strict') self.control = '' self.cells = [[self.blank() for _ in range(width)] for _ in range(height)]
def blank(self): return (' ', self.fg, self.bg, self.bold)
def resize(self, width, height): assert width > 0 and height > 0 self.cells = [(row[:width] + [self.blank()] * max(0, width-len(row))) for row in self.cells[:height]] self.cells += [[self.blank() for _ in range(width)] for _ in range(height-len(self.cells))] self.width, self.height = width, height self.x, self.y = min(self.x, width-1), min(self.y, height-1) self.pending_wrap = False
def feed(self, data): for char in self.decoder.decode(data): if self.control: self.control += char if self.control == '\x1b[': continue if len(self.control) == 2: raise ValueError('unsupported escape %r' % self.control) if '@' <= char <= '~': command, self.control = self.control, '' self.csi(command[2:-1], command[-1]) elif not (' ' <= char <= '?'): raise ValueError('malformed CSI %r' % self.control) continue if char == '\x1b': self.control = char elif char == '\r': self.x, self.pending_wrap = 0, False elif char == '\n': self.linefeed() elif char == '\b': self.x, self.pending_wrap = max(0, self.x-1), False elif char == '\t': self.x = min(self.width-1, (self.x//8+1)*8) self.pending_wrap = False elif ord(char) < 32 or ord(char) == 127: raise ValueError('unsupported control %r' % char) else: if unicodedata.combining(char) or unicodedata.east_asian_width(char) in ('W', 'F'): raise ValueError('unsupported cell width %r' % char) if self.pending_wrap: self.x = 0 self.linefeed() self.cells[self.y][self.x] = (char, self.fg, self.bg, self.bold) if self.x == self.width-1: self.pending_wrap = self.wrap else: self.x += 1
def finish(self): self.decoder.decode(b'', final=True) if self.control: raise ValueError('incomplete control %r' % self.control)
def linefeed(self): self.pending_wrap = False self.y += 1 if self.y == self.height: self.cells.pop(0) self.cells.append([self.blank() for _ in range(self.width)]) self.y -= 1
def csi(self, params, final): if params.startswith('?') and final in 'hl': enabled = final == 'h' for mode in params[1:].split(';'): if mode == '7': self.wrap = enabled self.pending_wrap = False elif mode == '25': self.visible = enabled elif mode == '1049': if enabled and not self.alternate: self.saved = (self.cells, self.x, self.y, self.fg, self.bg, self.bold, self.pending_wrap) self.cells = [[self.blank() for _ in range(self.width)] for _ in range(self.height)] self.x = self.y = 0 self.pending_wrap = False elif not enabled and self.alternate: self.cells, self.x, self.y, self.fg, self.bg, self.bold, self.pending_wrap = self.saved self.resize(self.width, self.height) self.alternate = enabled elif mode in ('1000', '1002', '1003', '1004', '1006', '1015', '2004'): pass # input reporting modes have no display effect else: raise ValueError('unsupported private mode ' + mode) return try: values = [int(p) if p else 0 for p in params.split(';')] except ValueError as error: raise ValueError('unsupported CSI %r%s' % (params, final)) from error if final in 'Hf' and len(values) <= 2: row, col = (values + [0])[:2] self.y, self.x = min(self.height-1, max(1, row)-1), min(self.width-1, max(1, col)-1) self.pending_wrap = False elif final in 'JK' and len(values) == 1 and values[0] in (0, 1, 2): mode = values[0] cursor = self.y*self.width+self.x for y in range(self.height): for x in range(self.width): index = y*self.width+x if (final == 'J' or y == self.y) and (mode == 2 or (mode == 0 and index >= cursor) or (mode == 1 and index <= cursor)): self.cells[y][x] = self.blank() elif final == 'm': while values: value = values.pop(0) if value == 0: self.fg = self.bg = None self.bold = False elif value in (1, 22): self.bold = value == 1 elif value in (39, 49): setattr(self, 'fg' if value == 39 else 'bg', None) elif 30 <= value <= 37 or 90 <= value <= 97: self.fg = value-30 if value < 90 else value-90+8 elif 40 <= value <= 47 or 100 <= value <= 107: self.bg = value-40 if value < 100 else value-100+8 elif value in (38, 48): if not values: raise ValueError('missing SGR color mode') mode = values.pop(0) count = {2: 3, 5: 1}.get(mode) if count is None or len(values) < count or any(not 0 <= v <= 255 for v in values[:count]): raise ValueError('invalid SGR color') color = tuple(values[:count]) if count == 3 else values[0] del values[:count] setattr(self, 'fg' if value == 38 else 'bg', color) else: raise ValueError('unsupported SGR %d' % value) else: raise ValueError('unsupported CSI %r%s' % (params, final))
def text(self): return '\n'.join(''.join(cell[0] for cell in row) for row in self.cells)
def snapshot(self): return {'width': self.width, 'height': self.height, 'cell_aspect': [1, 2], 'cursor': [self.x, self.y], 'cursor_visible': self.visible, 'terminal_defaults': {'fg': None, 'bg': None}, 'pen': [self.fg, self.bg, self.bold], 'alternate': self.alternate, 'cells': self.cells}
def rgb(color, default): if color is None: return default if isinstance(color, tuple): return '#%02x%02x%02x' % color basic = ('#000000','#800000','#008000','#808000','#000080','#800080','#008080','#c0c0c0', '#808080','#ff0000','#00ff00','#ffff00','#0000ff','#ff00ff','#00ffff','#ffffff') if color < 16: return basic[color] if color >= 232: return '#%02x%02x%02x' % ((8+10*(color-232),)*3) n = color-16 levels = (0,95,135,175,215,255) return '#%02x%02x%02x' % (levels[n//36], levels[n//6%6], levels[n%6])
METADATA_KEYS = frozenset(('frame','mode','look_active','yaw','pitch','elevation','selected_z', 'player_z','tick','diagnostic_hash','history_len','width','height', 'color_mode','mouse_capture','suspended'))
def export(vt, name, metadata): """No output unless opted in. No paths, timestamps, IDs or raw bytes exported.""" root = os.environ.get('HEXWORK_PTY_ARTIFACT_DIR') if not root: return if not name or any(c not in 'abcdefghijklmnopqrstuvwxyz0123456789-_' for c in name): raise ValueError('unsafe artifact name') clean = {key: value for key, value in metadata.items() if key in METADATA_KEYS} for key, value in clean.items(): if isinstance(value, str) and (len(value) > 128 or any(ord(c) < 32 for c in value) or '/' in value or '\\' in value): raise ValueError('unsafe metadata: ' + key) if value is not None and not isinstance(value, (str, int, float, bool)): raise ValueError('non-scalar metadata: ' + key) path = pathlib.Path(root) path.mkdir(parents=True, exist_ok=True) payload = {'schema': 1, 'metadata': clean, 'screen': vt.snapshot()} (path/(name+'.json')).write_text(json.dumps(payload, sort_keys=True, ensure_ascii=True, allow_nan=False, separators=(',', ':'))+'\n') parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">' % (vt.width*10, vt.height*20, vt.width*10, vt.height*20), '<metadata>'+html.escape(json.dumps({'cell_aspect':[1,2], 'metadata':clean}, sort_keys=True))+'</metadata>'] for y, row in enumerate(vt.cells): for x, (glyph, fg, bg, bold) in enumerate(row): parts.append('<rect x="%d" y="%d" width="10" height="20" fill="%s"/>' % (x*10,y*20,rgb(bg,'#000000'))) parts.append('<text x="%d" y="%d" font-family="monospace" font-size="16" font-weight="%s" fill="%s">%s</text>' % (x*10,y*20+16,'bold' if bold else 'normal',rgb(fg,'#c0c0c0'),html.escape(glyph))) (path/(name+'.svg')).write_text('\n'.join(parts+['</svg>'])+'\n')