joeyagi/tests/agiGame.py
2026-10-06 16:59:41 -05:00

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