489 lines
12 KiB
Python
489 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# agiGame.py - build small AGI v2 games from scratch for JoeyAGI's tests:
|
|
# LOGIC code from a Python description, PICTURE command lists, VIEWs from
|
|
# text art, SOUNDs, WORDS.TOK, OBJECT, the four directories and VOL.0.
|
|
#
|
|
# Everything here follows the public AGI specifications (the LOGIC command
|
|
# table, chapter 5's directory and VOL formats, chapters 6-10). Nothing is
|
|
# copied from a Sierra game, so the games it writes can live in the repo's
|
|
# tests and be fed to Sierra's own interpreter as well (scripts/oracle.sh).
|
|
#
|
|
# L = Logic()
|
|
# with L.If(("isset", 5)):
|
|
# L.load_pic(...) # commands by name, '.' written as '_'
|
|
# L.return_()
|
|
# writeGame(dir, logics={0: L.build()}, pics={...}, views={...})
|
|
|
|
import os
|
|
import struct
|
|
|
|
# Action commands in number order, with their argument counts.
|
|
ACTIONS = """return 0
|
|
increment 1
|
|
decrement 1
|
|
assignn 2
|
|
assignv 2
|
|
addn 2
|
|
addv 2
|
|
subn 2
|
|
subv 2
|
|
lindirectv 2
|
|
rindirect 2
|
|
lindirectn 2
|
|
set 1
|
|
reset 1
|
|
toggle 1
|
|
set.v 1
|
|
reset.v 1
|
|
toggle.v 1
|
|
new.room 1
|
|
new.room.v 1
|
|
load.logics 1
|
|
load.logics.v 1
|
|
call 1
|
|
call.v 1
|
|
load.pic 1
|
|
draw.pic 1
|
|
show.pic 0
|
|
discard.pic 1
|
|
overlay.pic 1
|
|
show.pri.screen 0
|
|
load.view 1
|
|
load.view.v 1
|
|
discard.view 1
|
|
animate.obj 1
|
|
unanimate.all 0
|
|
draw 1
|
|
erase 1
|
|
position 3
|
|
position.v 3
|
|
get.posn 3
|
|
reposition 3
|
|
set.view 2
|
|
set.view.v 2
|
|
set.loop 2
|
|
set.loop.v 2
|
|
fix.loop 1
|
|
release.loop 1
|
|
set.cel 2
|
|
set.cel.v 2
|
|
last.cel 2
|
|
current.cel 2
|
|
current.loop 2
|
|
current.view 2
|
|
number.of.loops 2
|
|
set.priority 2
|
|
set.priority.v 2
|
|
release.priority 1
|
|
get.priority 2
|
|
stop.update 1
|
|
start.update 1
|
|
force.update 1
|
|
ignore.horizon 1
|
|
observe.horizon 1
|
|
set.horizon 1
|
|
object.on.water 1
|
|
object.on.land 1
|
|
object.on.anything 1
|
|
ignore.objs 1
|
|
observe.objs 1
|
|
distance 3
|
|
stop.cycling 1
|
|
start.cycling 1
|
|
normal.cycle 1
|
|
end.of.loop 2
|
|
reverse.cycle 1
|
|
reverse.loop 2
|
|
cycle.time 2
|
|
stop.motion 1
|
|
start.motion 1
|
|
step.size 2
|
|
step.time 2
|
|
move.obj 5
|
|
move.obj.v 5
|
|
follow.ego 3
|
|
wander 1
|
|
normal.motion 1
|
|
set.dir 2
|
|
get.dir 2
|
|
ignore.blocks 1
|
|
observe.blocks 1
|
|
block 4
|
|
unblock 0
|
|
get 1
|
|
get.v 1
|
|
drop 1
|
|
put 2
|
|
put.v 2
|
|
get.room.v 2
|
|
load.sound 1
|
|
sound 2
|
|
stop.sound 0
|
|
print 1
|
|
print.v 1
|
|
display 3
|
|
display.v 3
|
|
clear.lines 3
|
|
text.screen 0
|
|
graphics 0
|
|
set.cursor.char 1
|
|
set.text.attribute 2
|
|
shake.screen 1
|
|
configure.screen 3
|
|
status.line.on 0
|
|
status.line.off 0
|
|
set.string 2
|
|
get.string 5
|
|
word.to.string 2
|
|
parse 1
|
|
get.num 2
|
|
prevent.input 0
|
|
accept.input 0
|
|
set.key 3
|
|
add.to.pic 7
|
|
add.to.pic.v 7
|
|
status 0
|
|
save.game 0
|
|
restore.game 0
|
|
init.disk 0
|
|
restart.game 0
|
|
show.obj 1
|
|
random 3
|
|
program.control 0
|
|
player.control 0
|
|
obj.status.v 1
|
|
quit 1
|
|
show.mem 0
|
|
pause 0
|
|
echo.line 0
|
|
cancel.line 0
|
|
init.joy 0
|
|
toggle.monitor 0
|
|
version 0
|
|
script.size 1
|
|
set.game.id 1
|
|
log 1
|
|
set.scan.start 0
|
|
reset.scan.start 0
|
|
reposition.to 3
|
|
reposition.to.v 3
|
|
trace.on 0
|
|
trace.info 3
|
|
print.at 4
|
|
print.at.v 4
|
|
discard.view.v 1
|
|
clear.text.rect 5
|
|
set.upper.left 2
|
|
set.menu 1
|
|
set.menu.item 2
|
|
submit.menu 0
|
|
enable.item 1
|
|
disable.item 1
|
|
menu.input 0
|
|
show.obj.v 1
|
|
open.dialogue 0
|
|
close.dialogue 0
|
|
mul.n 2
|
|
mul.v 2
|
|
div.n 2
|
|
div.v 2
|
|
close.window 0"""
|
|
|
|
# Test commands in number order (said takes a word list).
|
|
TESTS = """_ 0
|
|
equaln 2
|
|
equalv 2
|
|
lessn 2
|
|
lessv 2
|
|
greatern 2
|
|
greaterv 2
|
|
isset 1
|
|
isset.v 1
|
|
has 1
|
|
obj.in.room 2
|
|
posn 5
|
|
controller 1
|
|
have.key 0
|
|
said -1
|
|
compare.strings 2
|
|
obj.in.box 5
|
|
center.posn 5
|
|
right.posn 5"""
|
|
|
|
ACT = {}
|
|
for _i, _line in enumerate(ACTIONS.splitlines()):
|
|
_n, _c = _line.split()
|
|
ACT[_n] = (_i, int(_c))
|
|
TST = {}
|
|
for _i, _line in enumerate(TESTS.splitlines()):
|
|
_n, _c = _line.split()
|
|
TST[_n] = (_i, int(_c))
|
|
|
|
CRYPT_KEY = b"Avis Durgan"
|
|
COND_START = 0xFF
|
|
COND_END = 0xFF
|
|
COND_OR = 0xFC
|
|
COND_NOT = 0xFD
|
|
CODE_GOTO = 0xFE
|
|
VOL_SIGNATURE = b"\x12\x34"
|
|
DIR_EMPTY = b"\xff\xff\xff"
|
|
LETTERS = 26
|
|
WORD_CHAR_XOR = 0x7F
|
|
WORD_LAST_CHAR = 0x80
|
|
VOICES = 4
|
|
SOUND_END = b"\xff\xff"
|
|
|
|
# PICTURE commands (specification 7.2).
|
|
PIC_COLOR = 0xF0
|
|
PIC_NO_COLOR = 0xF1
|
|
PIC_PRIORITY = 0xF2
|
|
PIC_NO_PRIORITY = 0xF3
|
|
PIC_Y_CORNER = 0xF4
|
|
PIC_X_CORNER = 0xF5
|
|
PIC_LINE = 0xF6
|
|
PIC_REL_LINE = 0xF7
|
|
PIC_FILL = 0xF8
|
|
PIC_PEN = 0xF9
|
|
PIC_PLOT = 0xFA
|
|
PIC_END = 0xFF
|
|
|
|
|
|
def crypt(data):
|
|
return bytes(c ^ CRYPT_KEY[i % len(CRYPT_KEY)] for i, c in enumerate(data))
|
|
|
|
|
|
class Logic:
|
|
def __init__(self):
|
|
self.code = bytearray()
|
|
self.msgs = []
|
|
|
|
def m(self, text):
|
|
# The message number for text (from 1), added on first use.
|
|
if text not in self.msgs:
|
|
self.msgs.append(text)
|
|
return self.msgs.index(text) + 1
|
|
|
|
def __getattr__(self, name):
|
|
cmd = name.rstrip("_").replace("_", ".")
|
|
if cmd not in ACT:
|
|
raise AttributeError(name)
|
|
|
|
def emit(*args):
|
|
op, n = ACT[cmd]
|
|
if len(args) != n:
|
|
raise ValueError("%s takes %d arguments" % (cmd, n))
|
|
self.code.append(op)
|
|
for a in args:
|
|
self.code.append(a & 0xFF)
|
|
return emit
|
|
|
|
def cond(self, tests):
|
|
# A term is ("name", args...), ("!", "name", args...) or
|
|
# ("or", [terms]); said takes word numbers.
|
|
out = bytearray([COND_START])
|
|
|
|
def term(t):
|
|
b = bytearray()
|
|
if t[0] == "or":
|
|
b.append(COND_OR)
|
|
for s in t[1]:
|
|
b += term(s)
|
|
b.append(COND_OR)
|
|
return b
|
|
if t[0] == "!":
|
|
b.append(COND_NOT)
|
|
t = t[1:]
|
|
num, n = TST[t[0]]
|
|
b.append(num)
|
|
if t[0] == "said":
|
|
b.append(len(t) - 1)
|
|
for w in t[1:]:
|
|
b += struct.pack("<H", w)
|
|
else:
|
|
if len(t) - 1 != n:
|
|
raise ValueError("%s takes %d arguments" % (t[0], n))
|
|
for a in t[1:]:
|
|
b.append(a & 0xFF)
|
|
return b
|
|
for t in tests:
|
|
out += term(t)
|
|
out.append(COND_END)
|
|
return out
|
|
|
|
def If(self, *tests):
|
|
return _IfBlock(self, list(tests))
|
|
|
|
def build(self):
|
|
# The code, then the message section: the count, the end of the
|
|
# text, an offset per message and the encrypted text.
|
|
code = bytes(self.code)
|
|
tableLen = 2 + 2 * len(self.msgs)
|
|
texts = b""
|
|
offsets = []
|
|
for t in self.msgs:
|
|
offsets.append(tableLen + len(texts))
|
|
texts += t.encode("ascii") + b"\0"
|
|
section = bytes([len(self.msgs)]) + struct.pack("<H", tableLen + len(texts))
|
|
for o in offsets:
|
|
section += struct.pack("<H", o)
|
|
return struct.pack("<H", len(code)) + code + section + crypt(texts)
|
|
|
|
|
|
class _IfBlock:
|
|
def __init__(self, logic, tests):
|
|
self.logic = logic
|
|
self.tests = tests
|
|
|
|
def __enter__(self):
|
|
self.logic.code += self.logic.cond(self.tests)
|
|
self.pos = len(self.logic.code)
|
|
self.logic.code += b"\0\0"
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
struct.pack_into("<H", self.logic.code, self.pos, len(self.logic.code) - (self.pos + 2))
|
|
|
|
|
|
def _rle(row, transparent):
|
|
# Runs of up to 15 pixels; a trailing transparent run is left out.
|
|
out = bytearray()
|
|
while row and row[-1] == transparent:
|
|
row = row[:-1]
|
|
i = 0
|
|
while i < len(row):
|
|
c = row[i]
|
|
n = 1
|
|
while i + n < len(row) and row[i + n] == c and n < 15:
|
|
n += 1
|
|
out.append((c << 4) | n)
|
|
i += n
|
|
out.append(0)
|
|
return out
|
|
|
|
|
|
def cel(art, transparent=0):
|
|
# A cel from rows of hex digits; '.' is the transparent colour.
|
|
rows = [[transparent if ch == "." else int(ch, 16) for ch in line] for line in art]
|
|
return (rows, transparent)
|
|
|
|
|
|
def view(loops, description=None):
|
|
# loops: a list whose items are a list of cels, or ("mirror", n) for a
|
|
# loop that shows loop n flipped. Mirrored cels are padded so the
|
|
# flipped run data fits where the original was (spec 8.2).
|
|
mirrors = {}
|
|
for i, lp in enumerate(loops):
|
|
if isinstance(lp, tuple):
|
|
mirrors[lp[1]] = i
|
|
header = bytearray([1, 1, len(loops), 0, 0]) + bytes(2 * len(loops))
|
|
body = bytearray()
|
|
loopAt = {}
|
|
base = len(header)
|
|
for i, lp in enumerate(loops):
|
|
if isinstance(lp, tuple):
|
|
continue
|
|
loopAt[i] = base + len(body)
|
|
start = len(body)
|
|
body += bytes([len(lp)]) + bytes(2 * len(lp))
|
|
for c, (rows, transparent) in enumerate(lp):
|
|
struct.pack_into("<H", body, start + 1 + 2 * c, len(body) - start)
|
|
flags = transparent
|
|
if i in mirrors:
|
|
flags |= 0x80 | (i << 4)
|
|
data = bytearray()
|
|
flipped = bytearray()
|
|
for r in rows:
|
|
data += _rle(r, transparent)
|
|
flipped += _rle(r[::-1], transparent)
|
|
data += bytes(max(0, len(flipped) - len(data)))
|
|
body += bytes([len(rows[0]), len(rows), flags]) + data
|
|
for i, lp in enumerate(loops):
|
|
target = loopAt[lp[1]] if isinstance(lp, tuple) else loopAt[i]
|
|
struct.pack_into("<H", header, 5 + 2 * i, target)
|
|
out = header + body
|
|
if description is not None:
|
|
struct.pack_into("<H", out, 3, len(out))
|
|
out += description.encode("ascii") + b"\0"
|
|
return bytes(out)
|
|
|
|
|
|
def sound(voices):
|
|
# voices: up to four lists of (duration, divisor, attenuation) notes;
|
|
# the fourth is the noise channel, whose divisor byte is the noise
|
|
# control value. Durations are 1/60 s ticks.
|
|
voices = list(voices) + [[]] * (VOICES - len(voices))
|
|
header = bytearray(2 * VOICES)
|
|
body = bytearray()
|
|
for v, notes in enumerate(voices):
|
|
struct.pack_into("<H", header, 2 * v, 2 * VOICES + len(body))
|
|
for duration, divisor, atten in notes:
|
|
if v == VOICES - 1:
|
|
freq = bytes([0, divisor & 0xFF])
|
|
else:
|
|
freq = bytes([(divisor >> 4) & 0x3F, 0x80 | (v << 5) | (divisor & 0x0F)])
|
|
body += struct.pack("<H", duration) + freq + bytes([0x90 | (v << 5) | (atten & 0x0F)])
|
|
body += SOUND_END
|
|
return bytes(header + body)
|
|
|
|
|
|
def words(table):
|
|
# WORDS.TOK from {word: number}: the letter index (big-endian), then
|
|
# the words in order, each sharing a prefix with the one before.
|
|
out = bytearray(2 * LETTERS)
|
|
prev = ""
|
|
for w in sorted(table):
|
|
letter = ord(w[0]) - ord("a")
|
|
if struct.unpack_from(">H", out, 2 * letter)[0] == 0:
|
|
struct.pack_into(">H", out, 2 * letter, len(out))
|
|
prev = ""
|
|
share = 0
|
|
limit = min(len(prev), len(w) - 1)
|
|
while share < limit and prev[share] == w[share]:
|
|
share += 1
|
|
out.append(share)
|
|
tail = w[share:]
|
|
for k, ch in enumerate(tail):
|
|
b = ord(ch) ^ WORD_CHAR_XOR
|
|
if k == len(tail) - 1:
|
|
b |= WORD_LAST_CHAR
|
|
out.append(b)
|
|
out += struct.pack(">H", table[w])
|
|
prev = w
|
|
out.append(0)
|
|
return bytes(out)
|
|
|
|
|
|
def objects(items, maxObjects=16):
|
|
# OBJECT from [(name, room)]: the header, an entry per item, the
|
|
# names; offsets count from the first entry. Encrypted.
|
|
entries = bytearray()
|
|
names = bytearray()
|
|
tableLen = 3 * len(items)
|
|
for name, room in items:
|
|
entries += struct.pack("<H", tableLen + len(names)) + bytes([room])
|
|
names += name.encode("ascii") + b"\0"
|
|
data = struct.pack("<H", tableLen) + bytes([maxObjects]) + entries + names
|
|
return crypt(data)
|
|
|
|
|
|
def writeGame(path, logics, pics=None, views=None, sounds=None, vocabulary=None, items=None, maxObjects=16):
|
|
# The directories and one VOL.0 holding every resource.
|
|
os.makedirs(path, exist_ok=True)
|
|
vol = bytearray()
|
|
for dirName, table in (("LOGDIR", logics), ("PICDIR", pics or {}), ("VIEWDIR", views or {}), ("SNDDIR", sounds or {})):
|
|
d = bytearray()
|
|
for num in sorted(table):
|
|
data = table[num]
|
|
off = len(vol)
|
|
vol += VOL_SIGNATURE + b"\x00" + struct.pack("<H", len(data)) + data
|
|
while len(d) < 3 * (num + 1):
|
|
d += DIR_EMPTY
|
|
d[3 * num:3 * num + 3] = bytes([(off >> 16) & 0x0F, (off >> 8) & 0xFF, off & 0xFF])
|
|
with open(os.path.join(path, dirName), "wb") as f:
|
|
f.write(d)
|
|
with open(os.path.join(path, "VOL.0"), "wb") as f:
|
|
f.write(vol)
|
|
with open(os.path.join(path, "WORDS.TOK"), "wb") as f:
|
|
f.write(words(vocabulary or {"a": 0}))
|
|
with open(os.path.join(path, "OBJECT"), "wb") as f:
|
|
f.write(objects(items or [("?", 0)], maxObjects))
|