Skip to content

Instantly share code, notes, and snippets.

@bbbradsmith
Last active May 4, 2021 02:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bbbradsmith/864232d2bcbda59ce7c9625747448218 to your computer and use it in GitHub Desktop.
Save bbbradsmith/864232d2bcbda59ce7c9625747448218 to your computer and use it in GitHub Desktop.
Aspetra (DOS) data file formats and python dump script
#!/usr/bin/env python3
#
# Python script for dumping data from Aspetra.
# Prerequisite: PIL
#
# Brad Smith, 2019
# http://rainwarrior.ca
#
# 2021-05-03 - Monster 0 is valid, object 157 disables monsters.
#
#
# Place "aspetra" game folder next to this script and run it.
# Or name the folder "aspetrasw" if shareware and change the
# SHAREWARE constant below to run it.
#
# An already complete dump along with detailed notes can be found at:
# http://rainwarrior.ca/projects/nes/aspetra.7z
#
import os
import PIL.Image
import PIL.ImageFont
import PIL.ImageDraw
SHAREWARE = False
#SHAREWARE = True
TRAINER = False
#TRAINER = True
if not SHAREWARE:
indir = "aspetra"
outdir = "dump"
print("Full version: %s => %s\n" %(indir,outdir))
else:
indir = "aspetrasw"
outdir = "dumpsw"
print("Shareware version: %s => %s\n" % (indir,outdir))
if TRAINER:
indir = "aspetrat"
outdir = "dumpt"
font = PIL.ImageFont.truetype("ProggyTiny.ttf",16)
# Font available here: https://proggyfonts.net/download/
# Font is 6x10 with the bottom 2 pixels as descenders (1 pixel margin on most glyphs)
map0dir = "map_base"
map1dir = "map_npcs"
map2dir = "map_objs"
map3dir = "map_coll"
outdir_map0 = os.path.join(outdir,map0dir)
outdir_map1 = os.path.join(outdir,map1dir)
outdir_map2 = os.path.join(outdir,map2dir)
outdir_map3 = os.path.join(outdir,map3dir)
if not os.path.exists(outdir_map0):
os.makedirs(outdir_map0)
if not os.path.exists(outdir_map1):
os.makedirs(outdir_map1)
if not os.path.exists(outdir_map2):
os.makedirs(outdir_map2)
if not os.path.exists(outdir_map3):
os.makedirs(outdir_map3)
TDEF = 109 # text default colour
SHOW_SPR_ATTRIBUTES = False
#
# Common utilities
#
def find_files(ext):
ext = ext.lower()
for dirpath, dirnames, filenames in os.walk(indir):
fo = []
for fn in filenames:
if fn.lower().endswith(ext):
fo.append(fn)
return fo
def read_file(f):
#print("Read: " + f)
f = os.path.join(indir, f)
return open(f,"rb").read()
def exists_file(f):
f = os.path.join(indir, f)
return os.path.exists(f)
def dump_file(f,data):
#print("Dump: " + f)
f = os.path.join(outdir, f)
open(f,"wb").write(data)
def dump_img(f,png):
print("Image: " + f)
f = os.path.join(outdir, f)
png.save(f)
def hexs(data):
s = ""
for b in data:
s += " %02X" % b
if len(s) > 0:
return s[1:]
return s
def text(x,y,s,img,c=109):
draw = PIL.ImageDraw.Draw(img)
draw.text((x,y),s,fill=c,font=font)
def text_outline(x,y,s,img,c=TDEF):
text(x-1,y-1,s,img,0)
text(x+0,y-1,s,img,0)
text(x+1,y-1,s,img,0)
text(x-1,y-0,s,img,0)
text(x+1,y+0,s,img,0)
text(x-1,y+1,s,img,0)
text(x+0,y+1,s,img,0)
text(x+1,y+1,s,img,0)
text(x+0,y+0,s,img,c)
def shortfile(f):
return f.split('.')[0].upper()[0:8]
def signh(x):
return x if (x < 32768) else (x-65536)
def readh(data,index):
return data[index] | (data[index+1]<<8)
def writeh(data,index,value):
data[index+0] = value & 0xFF
data[index+1] = (value >> 8) & 0xFF
#
# Palette files .PAL and .PL2
#
def read_pal(f):
if (f.lower().endswith(".pl2")):
b = read_file(f)
print("PL2 [" + hexs(b[0:7]) + "] %d bytes (%f entries) %s" % (len(b)-7,(len(b)-7)/3,f)) # header
c = []
for i in range(7,len(b)):
p = b[i]
if (p >= 64):
print("Unexpectedly large value at byte %04X = %02X" % (i,p))
c.append((p * 4) & 0xFF)
return c
if (f.lower().endswith(".pal")):
b = read_file(f)
print("PAL [" + hexs(b[0:7]) + "] %d bytes (%f entries) %s" % (len(b)-7,(len(b)-7)/4,f)) # header
c = bytearray()
for i in range(7,len(b)):
p = b[i]
if ((i-7) % 4) == 3:
if p != 0:
print("Expected 0 in alpha channel at byte %04X = %02X" % (i,p))
continue
if (p >= 64):
print("Unexpectedly large value at byte %04X = %02X" % (i,p))
c.append((p * 4) & 0xFF)
return c
raise Exception() # unknown file type
def dump_pals():
print("Dumping palettes...\n")
for f in find_files(".pl2") + find_files(".pal"):
d = read_pal(f)
dump_file("pal."+f+".pal",bytes(d))
if shortfile(f) == "NIGHT2":
rd = bytearray()
for i in range(256):
rd.append(d[(255-i)*3+0])
rd.append(d[(255-i)*3+1])
rd.append(d[(255-i)*3+2])
dump_file("pal."+f+"_reverse.pal",rd)
print()
#
# Monsters .MON
# Bosses .BSS
# Same format, just different dimensions.
#
def dump_mons(pal, monster_list):
print("Dumping monsters...\n")
files = find_files(".mon")
rows = 18
if SHAREWARE:
rows = 6
columns = (len(files)+(rows-1)) // rows
d = 50
sx = 4 + d + d + d + d
#sx = 2 + d
sy = 2 + d + 9
img = PIL.Image.new("P",(columns*sx,rows*sy),255)
img.putpalette(pal)
for i in range(len(files)):
f = files[i]
b = read_file(f)
ds = d * d
# header, 4 byte prefix for second image, suffix
print("MON ["+hexs(b[0:11])+"] ["+hexs(b[ds+11:ds+17])+"] ["+hexs(b[ds+ds+17:])+"] " + f)
bx = (i // rows) * sx
by = (i % rows) * sy
for y in range(d):
for x in range(d):
p0 = 255 - b[11+x+(y*d)]
p1 = 255 - b[ds+17+x+(y*d)]
img.putpixel((bx+ 1+x,by+1+y),p0)
img.putpixel((bx+d+2+x,by+1+y),p1)
bx += (d+1)*2
# figure out name, if it's in the list
name = f
for j in range(len(monster_list)):
if shortfile(monster_list[j]) == shortfile(f):
name = "%d %s" % (j+1,monster_list[j])
text(bx+0,by+0,name,img)
# last 15 bytes are stats?
for j in range(15):
stat = readh(b,ds+ds+17+(j*2)+0)
statx = bx + ((j%3)*33)
staty = by + 10 + ((j//3)*10)
text(statx,staty,"%4d"%stat, img)
dump_img("mon.png",img)
print("Saved %d monsters" % len(files))
print()
def dump_bsss(pal):
print("Dumping bosses...\n")
files = find_files(".bss")
#files.remove("reth-ade.bss") # empty (reth1.rev sprite is special-case overlaid for this fight)
#files.remove("undeaddr.bss") # empty (undead dragon is already part of the map background)
rows = 6
if SHAREWARE:
rows = 4
columns = (len(files)+(rows-1)) // rows
d = 100
sx = 4 + d + d + d
#sx = 2 + d
sy = 2 + d
img = PIL.Image.new("P",(columns*sx,rows*sy),255)
img.putpalette(pal)
for i in range(len(files)):
f = files[i]
b = read_file(f)
ds = d * d
# header, 4 byte prefix for second image, suffix
print("BSS ["+hexs(b[0:11])+"] ["+hexs(b[ds+11:ds+17])+"] ["+hexs(b[ds+ds+17:])+"] " + f)
bx = (i // rows) * sx
by = (i % rows) * sy
for y in range(d):
for x in range(d):
p0 = 255 - b[11+x+(y*d)]
p1 = 255 - b[ds+17+x+(y*d)]
img.putpixel((bx+ 1+x,by+1+y),p0)
img.putpixel((bx+d+2+x,by+1+y),p1)
bx += (d+1)*2
text(bx+0,by+0,f,img)
# last 15 bytes are stats?
for j in range(15):
stat = readh(b,ds+ds+17+(j*2))
statx = bx + ((j%3)*33)
staty = by + 10 + ((j//3)*10)
text(statx,staty,"%4d"%stat, img)
dump_img("bss.png",img)
print("Saved %d bosses" % len(files))
print()
#
# Screenshots .BLD
#
def dump_bld(f,pal):
b = read_file(f)
dx = 320
dy = 200
ds = dx*dy
print("BLD ["+hexs(b[0:7])+"] ["+hexs(b[7+ds:])+"] " + f) # header + suffix
img = PIL.Image.new("P",(dx,dy),255)
img.putpalette(pal)
for y in range(dy):
for x in range(dx):
p = b[7+x+(y*dx)]
img.putpixel((x,y),p)
dump_img("bld."+f+".png",img)
def dump_blds(pal):
print("Dumping screenshots...\n")
for f in find_files(".bld"):
dump_bld(f,pal)
print()
#
# Srite tiles .SPR
#
def read_spr_attributes(f):
# extract collision and animation attributes from .SPR
b = read_file(f)
offset_collide = 0xFA07
offset_anim = 0xFB47
if len(b) < (offset_anim+320):
return [0] * 160
a = []
for i in range(160):
collide = readh(b,offset_collide+(i*2))
anim = readh(b,offset_anim+(i*2))
assert collide == (collide & 1) and anim == (anim & 1)
a.append(collide | (anim<<1))
return a
def dump_spr(f,pal,sprname_list,columns=12):
COLL = 23 # collide colour
ANIM = 42 # animation colour
b = read_file(f)
dx = 20
dy = 20
ds = dx*dy
count = ((len(b)-11)+(ds+4-1))//(ds+4)
rows = 1+((count+(columns-1))//columns)
print("SPR ["+hexs(b[0:11])+"] " + f + " (%d tiles)" % count) # header
img = PIL.Image.new("P",(1+(columns*(dx+1)),1+(rows*(dy+1))),255)
img.putpalette(pal)
realcount = 0
if count > 158: # the space for the last 2 tiles contains attribute data instead
count = 158
attrib = read_spr_attributes(f)
for t in range(count):
tb = 11 + (t * (ds+4))
prefix_x = readh(b,tb-4)
prefix_y = readh(b,tb-2)
if (prefix_x == 0 and prefix_y == 0): # these are empty
empty = True
#for i in range(ds):
# empty &= (b[tb+i]==0)
if empty:
continue
realcount += 1
#assert (prefix_x == 20 and prefix_y == 20) # camp.spr, maybe others have 160x20?
#print(" tile %3d (%d,%d)" % (t, prefix_x, prefix_y))
tx = t % columns
ty = t // columns
ox = 1+(tx*(dx+1))
oy = 1+(ty*(dy+1))
for y in range(dy):
for x in range(dx):
p = b[tb+(y*dx)+x]
img.putpixel((x+ox,y+oy),p)
if attrib[t] and SHOW_SPR_ATTRIBUTES: # outline the top left corner to mark attributes
a = attrib[t]
c0 = [ 255, COLL, ANIM, COLL ]
c1 = [ 255, COLL, ANIM, ANIM ]
for x in range(dx):
c = c1[a] if ((x>>2)&1) else c0[a]
img.putpixel((ox+x,oy-1),c)
img.putpixel((ox-1,oy+x),c)
name = f
for i in range(len(sprname_list)):
if sprname_list[i] == shortfile(f):
name = "%d %s" % (i+1,name)
text(1,img.height-11,name + " (%d/%d tiles)" % (realcount,count),img)
dump_img("spr."+f+".png",img)
def dump_sprs(pal,sprname_list):
print("Dumping .SPR graphic tiles...\n")
files = find_files(".spr")
#if exists_file("deaddrag.rev"): # this file seems to be a SPR in disguise
# files.append("deaddrag.rev")
for f in files:
dump_spr(f,pal,sprname_list)
print()
#
# Additional sprite tiles .REV
#
def dump_rev(f,pal,objname_list,columns=12):
b = read_file(f)
dx = 20
dy = 20
ds = dx*dy
count = ((len(b)-11)+(ds+4-1))//(ds+4)
rows = 1+((count+(columns-1))//columns)
print("REV ["+hexs(b[0:11])+"] " + f + " (%d tiles)" % count) # header
img = PIL.Image.new("P",(1+(columns*(dx+1)),1+(rows*(dy+1))),255)
img.putpalette(pal)
realcount = 0
for t in range(count):
tb = 11 + (t * (ds+4))
prefix_x = readh(b,tb-4)
prefix_y = readh(b,tb-2)
if (prefix_x == 0 and prefix_y == 0): # these are empty
empty = True
#for i in range(ds):
# empty &= (b[tb+i]==0)
if empty:
continue
realcount += 1
#assert (prefix_x == 20 and prefix_y == 20) # temp.rev says 160x20 but the data is clearly 20x20?
#print(" tile %3d (%d,%d)" % (t, prefix_x, prefix_y))
if t == 0: # put 0 tile in bottom right corner
tx = columns-1
ty = rows - 1
else:
tx = (t-1) % columns
ty = (t-1) // columns
ox = 1+(tx*(dx+1))
oy = 1+(ty*(dy+1))
#for dumping reth1.rev in an easy arrangement:
#ox = 1+(([0,4,8,1,5,9,2,6,10,3,7,11][(t-1)%12])*(dx+0))
for y in range(dy):
for x in range(dx):
p = b[tb+(y*dx)+x]
img.putpixel((x+ox,y+oy),p)
name = f
for i in range(len(objname_list)):
if objname_list[i] == shortfile(f):
name = "%d %s" % (i+1, name)
text(1,img.height-11,name + " (%d/%d tiles)" % (realcount,count),img)
text(img.width-dx-(7*6+2),img.height-11,"tile 0>",img)
dump_img("rev."+f+".png",img)
def dump_revs(pal,objname_list):
print("Dumping .REV additional sprites...\n")
for f in find_files(".rev"):
dump_rev(f,pal,objname_list)
print()
#
# Magic graphics tiles .GFX
#
def dump_gfx(f,pal,magic_list,columns=12):
b = read_file(f)
dx = 20
dy = 20
ds = dx*dy
count = ((len(b)-11)+(ds+4-1))//(ds+4)
rows = 1+((count+(columns-1))//columns)
print("GFX ["+hexs(b[0:11])+"] " + f + " (%d tiles)" % count) # header
img = PIL.Image.new("P",(1+(columns*(dx+1)),1+(rows*(dy+1))),255)
img.putpalette(pal)
realcount = 0
for t in range(count):
tb = 11 + (t * (ds+4))
prefix_x = readh(b,tb-4)
prefix_y = readh(b,tb-2)
if (prefix_x == 0 and prefix_y == 0): # these seem to be always empty
empty = True
for i in range(ds):
empty &= (b[tb+i]==0)
if empty:
continue
realcount += 1
assert (prefix_x == 20 and prefix_y == 20)
#print(" tile %3d (%d,%d)" % (t, prefix_x, prefix_y))
if t == 0: # put 0 tile in bottom right corner
tx = columns-1
ty = rows - 1
else:
tx = (t-1) % columns
ty = (t-1) // columns
ox = 1+(tx*(dx+1))
oy = 1+(ty*(dy+1))
for y in range(dy):
for x in range(dx):
p = b[tb+(y*dx)+x]
img.putpixel((x+ox,y+oy),p)
name = f
for i in range(len(magic_list)):
if shortfile(magic_list[i]) == shortfile(f):
name = "%d %s" % (i,magic_list[i])
text(1,img.height-11,name + " (%d/%d tiles)" % (realcount,count),img)
text(img.width-dx-(7*6+2),img.height-11,"tile 0>",img)
dump_img("gfx."+f+".png",img)
def dump_gfxs(pal,magic_list):
print("Dumping .GFX magic sprites...\n")
for f in find_files(".gfx"):
dump_gfx(f,pal,magic_list)
print()
#
# Player battle sprites .MAN
#
def dump_man(f,pal,columns=2):
b = read_file(f)
dx = 30
dy = 30
ds = dx*dy
header = 13
if f.lower() == "death.man": # no idea why this one is shorter?
header = 11
count = ((len(b)-header)+(ds+4-1))//(ds+4)
rows = (count+(columns-1))//columns
print("MAN ["+hexs(b[0:header])+"] " + f + " (%d tiles)" % count) # header
img = PIL.Image.new("P",(1+(columns*(dx+1)),1+(rows*(dy+1))),255)
img.putpalette(pal)
realcount = 0
for t in range(count):
tb = header + (t * (ds+4))
prefix_x = readh(b,tb-4)
prefix_y = readh(b,tb-2)
if (prefix_x == 0 and prefix_y == 0): # these seem to be always empty
empty = True
for i in range(ds):
empty &= (b[tb+i]==0)
if empty:
continue
realcount += 1
tx = t % columns
ty = t // columns
ox = 1+(tx*(dx+1))
oy = 1+(ty*(dy+1))
for y in range(dy):
for x in range(dx):
p = 255 - b[tb+(y*dx)+x]
img.putpixel((x+ox,y+oy),p)
name = f
dump_img("man."+f+".png",img)
def dump_mans(pal):
print("Dumping .MAN player battle sprites...\n")
for f in find_files(".man"):
dump_man(f,pal)
print()
#
# Font .FNT
#
def dump_fnt(f,pal):
b = read_file(f)
dx = 8
dy = 8
ds = dx*dy
count = ((len(b)-11)+(ds+4-1))//(ds+4)
columns = 16
rows = (count+(columns-1))//columns
print("FNT ["+hexs(b[0:11])+"] " + f + " (%d tiles)" % count) # header
img = PIL.Image.new("P",(1+(columns*(dx+1)),1+(rows*(dy+1))),255)
img.putpalette(pal)
realcount = 0
for t in range(count):
tb = 11 + (t * (ds+4))
prefix_x = readh(b,tb-4)
prefix_y = readh(b,tb-2)
if (prefix_x == 0 and prefix_y == 0): # these seem to be always empty
empty = True
for i in range(ds):
empty &= (b[tb+i]==0)
if empty:
continue
realcount += 1
tx = t % columns
ty = t // columns
ox = 1+(tx*(dx+1))
oy = 1+(ty*(dy+1))
for y in range(dy):
for x in range(dx):
p = b[tb+(y*dx)+x]
img.putpixel((x+ox,y+oy),p)
name = f
dump_img("fnt."+f+".png",img)
def dump_fnts(pal):
print("Dumping .FNT fonts...\n")
for f in find_files(".fnt"):
dump_fnt(f,pal)
print()
#
# Decipher .LST that has been "encrypted"
#
def bin_lines(b):
return b.decode("ASCII").replace("\r","").split("\n")
def read_simple_lst(f):
l = bin_lines(read_file(f))
while l[len(l)-1] == "": #remove trailing empties
l = l[0:len(l)-1]
return l
def decrypt_lst(f):
lines = read_file(f).decode("ASCII").replace("\r","").split("\n")
b = bytearray()
for i in range(len(lines)):
line = lines[i]
if len(line) > 0 and line[0] == '~':
b.extend((line+"\r\n").encode("ASCII"))
else:
nl = [x-1 for x in line.encode("ASCII")]
b.extend(nl)
b.extend("\r\n".encode("ASCII"))
return b
dump_file("lst."+f+".txt",b)
def read_magic_lst(f): # read an list of magic names for indexing GFX data
b = decrypt_lst(f)
lines = bin_lines(b)
magic = []
for i in range(len(lines)):
l = lines[i]
if len(l) > 0 and l[0] == '~':
m = lines[i+1]
if (len(m) > 0):
index = int(l[1:])
while len(magic) <= index:
magic.append("")
#print("Magic %d = %s" % (index,m))
magic[index] = m
return magic
def treasure_lookup(t,item_list):
s = "%4d,%4d " % t
if t[0] in item_list:
s += item_list[t[0]]
elif t[0] == 1000:
s += "GP"
else:
s += "?"
return s
def read_treasure_lst(f):
treasure = {}
lines = bin_lines(read_file(f))
for i in range(len(lines)):
l = lines[i]
if len(l) > 0 and l[0] == '~':
index = int(l[1:])
if index == 0:
break
if index in treasure:
print("Duplicate treasure index: %d" % index)
ts = lines[i+1].split(",")
t = (int(ts[0]),int(ts[1]))
treasure[index] = t
#print("%4d: %d,%d" % (index,treasure[index][0],treasure[index][1]))
return treasure
def dump_treasure_lst(f,treasure_list,treasure_used,item_list):
s = ""
for index in sorted(treasure_list.keys()):
s += "%4d: %-24s [" % (index,treasure_lookup(treasure_list[index],item_list))
if index in treasure_used:
s += treasure_used[index]
s += "]\r\n"
dump_file(f,s.encode("ASCII"))
def dump_monster_lst(f, monster_list, monster_used):
s = ""
for m in range(len(monster_list)):
if m+1 in monster_used:
sm = ""
for mf in monster_used[m+1]:
sm += "," + mf
sm = "[" + sm[1:] + "]"
else:
sm = "UNUSED"
s += "%3d %-14s %s\n" % (m+1,monster_list[m],sm)
dump_file(f,s.encode("ASCII"))
def read_item_lsts(lsts):
items = {}
for lst in lsts:
if lst.lower() == "rare.lst":
lines = bin_lines(read_file(lst)) # this one not encrypted?
else:
lines = bin_lines(decrypt_lst(lst))
for i in range(len(lines)):
l = lines[i]
if len(l) > 0 and l[0] == '~':
index = int(l[1:])
if index == 0:
break
if index in items:
print("Duplicate item index: %d" % index)
items[index] = lines[i+1]
#print("%4d: %s" % (index,items[index]))
s = ""
for index in sorted(items.keys()):
s += "%4d: %s\r\n" % (index,items[index])
dump_file("lst.items_all.txt",s.encode("ASCII"))
return items
def decrypt_lsts(lsts):
print("Decyphering .LST files...\n")
for l in lsts:
b = decrypt_lst(l)
dump_file("lst."+l+".txt",b)
print()
#
# Maps
# .MAP - map graphical layout
# .ALT - event locations?
# .CNV - conversations
# .EVT - event scripts
# .DWM - music
#
def prepare_tiles(f): # abbreviated .SPR reader
b = read_file(f)
tiles = []
pos = 11
dx = 20
dy = 20
while (pos + (dx*dy)) <= len(b):
tile = []
for y in range(dy):
row = [b[pos+(y*dx)+x] for x in range(dx)]
tile.append(row)
tiles.append(tile)
pos += (dx*dy)+4
return tiles
def draw_tile(img,t,tx,ty,tiles):
dx = 20
dy = 20
ox = tx * dx
oy = ty * dy
tile = tiles[t]
for y in range(dy):
for x in range(dx):
img.putpixel((ox+x,oy+y),tile[y][x])
def draw_tile_masked(img,t,tx,ty,tiles):
dx = 20
dy = 20
ox = tx * dx
oy = ty * dy
tile = tiles[t]
for y in range(dy):
for x in range(dx):
p = tile[y][x]
if p > 0:
img.putpixel((ox+x,oy+y),p)
def draw_tile_solid(img,p,tx,ty):
dx = 20
dy = 20
ox = tx * dx
oy = ty * dy
for y in range(dy):
for x in range(dx):
img.putpixel((ox+x,oy+y),p)
def dump_map(f,pal,alt=None,spr_default="MAIN.SPR",rev_default="SENTRY.REV"):
global wrldname_list
global sprname_list
global objname_list
global monster_list
global treasure_list
global item_list
global treasure_used
global monster_used
L0 = 45 # colour for data in tile layer
L1 = 61 # colour for data in object layer
GRID = 17 # colour for grid guide
COORD = 21 # colour for grid coordinates
CERR = 25 # colour for error
CNV = 203
EVT = 124
SHOP = 219
TREASURE = 143
NPC = 73
COLL = [ 0, 15 ] # collision colours empty and blocking
print("Dumping .MAP: " + f)
b = read_file(f)
def lend(layer,row,entry): # data stored at ends of rows (3 entries per row)
return readh(b,7+(layer*(67*64*2))+(67*2*row)+(64*2)+(entry*2))
def npcpos(nc): # 30 NPCs are stored in columns, 7 rows per entry, on both layers
assert nc<30
r = 7 * (nc % 9)
l = (nc // 9) & 1
c = 1 + ((nc // 9) >> 1)
return (l,r,c)
wrld_index = 0
for i in range(len(wrldname_list)):
w = wrldname_list[i]
if shortfile(f) == w:
wrld_index = i+1
# possible associated files
falt = shortfile(f) + ".ALT"
fcnv = shortfile(f) + ".CNV"
fevt = shortfile(f) + ".EVT"
fdwm = shortfile(f)[0:4] + ".DWM"
# apply ALT
aname = ""
if alt != None:
b = bytearray(b)
aflag = alt[0]
atile = alt[1]
anpc = alt[2]
aname = ".alt.%d.%d" % (aflag[0],aflag[1])
#print(aname)
for (x,y,l,v) in atile:
#print((x,y,l,v))
writeh(b,7+(l*67*64*2)+(y*67*2)+(x*2),v)
for (e,p,v) in anpc: # NPC has 7 bytes of data in a lend column
#print((e,p,v))
if (e,p,v)==(-2,-2,-2): # I think this is "hide all" (-2 to property 3 of all entities)
for i in range(30):
(l,r,c) = npcpos(i)
writeh(b,7+(l*67*64*2)+((r+3)*67*2)+(c*2)+(64*2),-2)
else:
(l,r,c) = npcpos(e)
writeh(b,7+(l*67*64*2)+((r+p)*67*2)+(c*2)+(64*2),v)
b = bytes(b)
# dump the .MAP
print("MAP: ["+hexs(b[0:7])+"]" + aname)
pos0 = 7 # tile layer
pos1 = 7+(67*64*2) # object layer
# determine associated tiles
fspr = spr_default
frev = rev_default
spr_name = "(" + spr_default + ")"
rev_name = "(" + rev_default + ")"
spr_index = lend(1,4,0)
rev_index = lend(1,5,0)
if spr_index > 0 and spr_index <= len(sprname_list):
fspr = sprname_list[spr_index-1] + ".SPR"
spr_name = fspr
if rev_index > 0 and rev_index <= len(objname_list):
frev = objname_list[rev_index-1] + ".REV"
rev_name = frev
if not exists_file(fspr):
fspr = spr_default
spr_name = "[" + spr_name + "]"
if not exists_file(frev):
frev = rev_default
rev_name = "[" + rev_name + "]"
spr_override = {
"castle9.map":"castle.spr",
"stones1.map":"stones.spr" }
if wrld_index == 0 and f.lower() in spr_override:
fspr = spr_override[f.lower()]
spr_name = "(" + fspr + ")"
spr_name = "%d %s" % (spr_index, spr_name)
rev_name = "%d %s" % (rev_index, rev_name)
tiles = prepare_tiles(fspr)
attributes = read_spr_attributes(fspr)
npc_tiles = prepare_tiles(frev)
# enumerate CNV and EVT
cnv_have = set()
evt_have = set()
if exists_file(fcnv):
lines = bin_lines(read_file(fcnv))
assert (lines[0][0] == '~')
cnv_have.add(int(lines[0][1:])) # if first line is ~0 it still counts?
cnv_end = False
for l in lines[1:]:
if len(l) > 0 and l[0] == '~':
cnv = int(l[1:])
if cnv == 0:
cnv_end = True
else:
assert (cnv_end==False)
cnv_have.add(cnv)
#for c in cnv_have:
# print("CNV: %d" % c)
if exists_file(fevt):
lines = bin_lines(read_file(fevt))
for l in lines:
if len(l) > 0 and l[0] == '`':
evt = int(l[1:])
evt_have.add(evt)
#for e in evt_have:
# print("EVT: %d" % e)
# render tile layer, collect objects
objs = []
img = PIL.Image.new("P",(64*20+230,64*20+(130)),255)
img.putpalette(pal)
for y in range(64):
for x in range(64):
# draw tile
t = readh(b,pos0+(x+(y*67))*2)
if t >= 160:
draw_tile(img,0,x,y,tiles)
t -= 160
if t < len(tiles):
draw_tile_masked(img,t,x,y,tiles)
elif t < len(tiles):
draw_tile(img,t,x,y,tiles)
# object list
o = readh(b,pos1+(x+(y*67))*2)
if o != 0:
objs.append((o,y,x))
for x in range(64*20,img.width):
img.putpixel((x,(y*20)),GRID) # grid guidelines for number dump on right
# dump of end-of-row data
rp = pos0+(67*2*y)+128
text( 16+ 1+(64*20), 1+(y*20),"%d"%readh(b,rp+0),img,L0)
text( 16+37+(64*20), 1+(y*20),"%d"%readh(b,rp+2),img,L0)
text( 16+73+(64*20), 1+(y*20),"%d"%readh(b,rp+4),img,L0)
text(126+ 1+(64*20), 1+(y*20),hexs(b[rp+0:rp+2]),img,L0)
text(126+37+(64*20), 1+(y*20),hexs(b[rp+2:rp+4]),img,L0)
text(126+73+(64*20), 1+(y*20),hexs(b[rp+4:rp+6]),img,L0)
text( 1+ 1+(64*20), 1+(y*20), "%d"%y,img,COORD)
rp = pos1+(67*2*y)+128
text( 16+ 1+(64*20),10+(y*20),"%d"%readh(b,rp+0),img,L1)
text( 16+37+(64*20),10+(y*20),"%d"%readh(b,rp+2),img,L1)
text( 16+73+(64*20),10+(y*20),"%d"%readh(b,rp+4),img,L1)
text(126+ 1+(64*20),10+(y*20),hexs(b[rp+0:rp+2]),img,L1)
text(126+37+(64*20),10+(y*20),hexs(b[rp+2:rp+4]),img,L1)
text(126+73+(64*20),10+(y*20),hexs(b[rp+4:rp+6]),img,L1)
# bottom grid
ry = 1+(64*20)
for i in range(64):
for y in range(11):
img.putpixel((i*20,ry+y-1),GRID)
text(i*20+2,ry,"%d"%i,img,21)
# associated files
name = f
if wrld_index > 0:
name = "%d %s" % (wrld_index, name)
nevt = "no .EVT" if not exists_file(fevt) else fevt
ncnv = "no .CNV" if not exists_file(fcnv) else fcnv
ndwm = "no .DWM" if not exists_file(fdwm) else fdwm
nalt = "no .ALT" if not exists_file(falt) else falt
ry += 15
text(1,ry+ 0,name + " [ " + hexs(b[0:7]) + " ]",img)
text(1,ry+ 10,spr_name,img,L1)
text(1,ry+ 20,rev_name,img,L1)
text(1,ry+ 30,nevt,img)
text(1,ry+ 40,ncnv,img)
text(1,ry+ 50,ndwm,img)
text(1,ry+ 60,nalt,img)
# connecting maps
nc = ["%d"%lend(1,i,0) for i in range(4)]
for i in range(len(nc)):
connect_dir = ["RIGHT: "," DOWN: "," LEFT: "," UP: "][i]
connect = lend(1,i,0)
connect_name = connect_dir + "%d" % connect
if connect > 0 and connect <= len(wrldname_list):
connect_name += " " + wrldname_list[connect-1]
elif connect > 0:
connect_name += " ?"
text(1,ry+70+(10*i),connect_name,img,L1)
# monsters
text(260,ry,"Monsters:",img)
for i in range(10):
mn = ""
m = lend(1,6+i,0)
if m > 0 and m <= len(monster_list):
mn = monster_list[m-1]
if m not in monster_used:
monster_used[m] = set()
monster_used[m].add(shortfile(f))
elif m == 0: # default monster?
mn = monster_list[0]
text(260,ry+(10*(i+1)),"%d %s" % (m,mn),img,L1)
# objects
rx = 401
colwid = 220
rows = 11
row = 0
for (o,y,x) in sorted(objs):
c = CERR
s = "?"
if o in evt_have:
c = EVT
s = "EVT"
elif o == 158 and o in cnv_have:
c = CNV
s = "CNV Weapon Shop"
elif o == 159 and o in cnv_have:
c = CNV
s = "CNV Item Shop"
elif o == 160 and o in cnv_have:
c = CNV
s = "CNV INN"
elif o == 157:
s = "No Monsters"
elif o >= 2000 and o <= 2061: # teleport
c = L1
tm = lend(0,o-2000,0)
tx = lend(0,o-1999,0)
ty = lend(0,o-1998,0)
s = "%2d,%2d %2d " % (tx,ty,tm)
if tm > 0 and tm <= len(wrldname_list):
s += wrldname_list[tm-1]
else:
s += "?"
elif o in treasure_list:
if alt==None:
if o in treasure_used:
treasure_used[o] = treasure_used[o] + " " + shortfile(f)
else:
treasure_used[o] = shortfile(f)
t = treasure_list[o]
c = TREASURE
s = treasure_lookup(t,item_list)
text(rx,ry+(row*10),"%2d,%2d %4d: %s"%(x,y,o,s),img,c)
row += 1
if row >= rows:
row = 0
rx += colwid
# export base map (no NPCs)
dump_img(os.path.join(map0dir,f+aname+".png"),img)
# render NPCs
npc_list = []
for npci in range(30):
(l,r,c) = npcpos(npci)
npc0 = lend(l,r+0,c) // 20 # X, reducing to tile for ease of reading
npc1 = lend(l,r+1,c) // 20 # Y
npc2 = lend(l,r+2,c) # sprite direction 1,2,3,4 = U,R,D,L
npc3 = signh(lend(l,r+3,c)) # active? -2 = hidden, -1 = stationary
npc4 = lend(l,r+4,c) # always 0?
npc5 = lend(l,r+5,c) # always 0?
npc6 = signh(lend(l,r+6,c)) # sprite row (*12)
assert npc4 == 0
assert npc5 == 0
npc_rev = 1 + (npc6 * 12)
if npc2 > 0: # not quite sure how this applies
npc_rev += (npc2-1) * 3
if npc6 == -1:
npc_rev = 0
if npc3 != -2:
npc_list.append((npc0,npc1,npci))
s = "%2d,%2d NPC: %2d (%2d,%3d)" % (npc0,npc1,npci,npc3,npc_rev)
text(rx,ry+(row*10),s,img,NPC)
row += 1
if row >= rows:
row = 0
rx += colwid
if npc_rev > 0 and npc_rev < len(npc_tiles):
draw_tile_masked(img,npc_rev,npc0,npc1,npc_tiles)
# export map with NPCs
dump_img(os.path.join(map1dir,f+aname+".png"),img)
# render object layer on top
def render_object_layer():
for y in range(64):
for x in range(64):
t = readh(b,pos1+(x+(y*67))*2)
c = CERR
if t >= 158 and t <= 160 and t in cnv_have:
c = CNV
elif t >= 2000 and t <= 2061:
c = L1
elif t in evt_have:
c = EVT
elif t in treasure_list:
c = TREASURE
if t > 0 and t < 1000:
desc = "%d" % t
text_outline(1+(x*20),1+(y*20),"%d"%t,img,c)
elif t >= 1000:
text_outline(1+(x*20), 1+(y*20),"%2d"%(t//100),img,c)
text_outline(1+(x*20),10+(y*20),"%02d"%(t%100),img,c)
# render NPC indices on top
for (x,y,ni) in npc_list:
if x < 64 and y < 64:
text_outline(1+(x*20),10+(y*20),"N%2d"%ni,img,NPC)
render_object_layer()
# export map with object layer
dump_img(os.path.join(map2dir,f+aname+".png"),img)
# render collision
for y in range(64):
for x in range(64):
t = readh(b,pos0+(x+(y*67))*2)
if t >= 160:
t = 0
a = attributes[t]
c = COLL[a&1]
draw_tile_solid(img,c,x,y)
render_object_layer()
# export map with collision and object layer
dump_img(os.path.join(map3dir,f+aname+".png"),img)
def dump_maps(pal):
print("Dumping .MAP maps...\n")
for f in find_files(".map"):
print()
dump_map(f,pal)
falt = shortfile(f) + ".ALT"
alts = []
if exists_file(falt):
lines = bin_lines(read_file(falt))
for i in range(len(lines)):
l = lines[i]
if len(l) > 0 and l[0] == '`':
if lines[i+1] == "-1,-1,-1,-1":
continue # some .ALTs with only treasures and no flagged stuff omit the flag
fl = lines[i+1].split(',')
flag = (int(fl[0]),int(fl[1]))
tiles = []
npcs = []
j = i+2
while True:
ls = lines[j].split(',')
j += 1
t = (int(ls[0]),int(ls[1]),int(ls[2]),int(ls[3]))
if t == (-1,-1,-1,-1):
break
tiles.append(t)
while True:
ls = lines[j].split(',')
j += 1
n = (int(ls[0]),int(ls[1]),int(ls[2]))
if n == (-1,-1,-1):
break
npcs.append(n)
if len(tiles) < 1 and len(npcs) < 1:
print("Empty ALT at line %d" % i)
else:
alts.append((flag,tiles,npcs))
for alt in alts:
dump_map(f,pal,alt)
print()
#
# Main
#
print("Read main palette...")
pal = read_pal("night2.pl2")
print()
# monster names are stored in a list file, first 8 characters may match filename
print("Read lists...")
monster_list = read_simple_lst( "monster.lst")
objname_list = read_simple_lst( "objname.lst")
sprname_list = read_simple_lst( "sprname.lst")
wrldname_list = read_simple_lst("wrldname.lst")
magic_list = read_magic_lst("magic.lst")
item_list = read_item_lsts(["item.lst","ring.lst","shield.lst","weapon.lst","rare.lst"])
treasure_list = read_treasure_lst("treasure.lst")
print()
treasure_used = {}
monster_used = {}
decrypt_lsts(["item.lst","magic.lst","ring.lst","shield.lst","weapon.lst"])
dump_pals()
dump_blds(pal)
dump_mons(pal,monster_list)
dump_bsss(pal)
dump_sprs(pal,sprname_list)
dump_revs(pal,objname_list)
dump_gfxs(pal,magic_list)
dump_mans(pal)
dump_fnts(pal)
dump_maps(pal)
dump_treasure_lst("lst.treasure.txt", treasure_list, treasure_used, item_list)
dump_monster_lst("lst.monster.txt", monster_list,monster_used)
Aspetra data format notes
Brad Smith, 2019-08-09, 2021-05-03
http://rainwarrior.ca/
The most recent version of this dump and dumper should be available here:
http://rainwarrior.ca/projects/nes/aspetra.7z
If you have any extra information to contribute, please let me know.
File Types
==========
Aspetra contains several varieties of data file:
.ALT - Alternate versions for .MAP
.BLD - Full screen image
.BSS - Boss enemy graphic and stats
.COL - Unknown
.CNV - Simple conversations for .MAP
.DWM - Music
.EVT - Scripted events for .MAP
.FNT - Font
.GFX - Magic and special attack graphics
.LST - Lists of various things
.MAN - Player character battle graphics
.MAP - Maps
.MON - Monster enemy graphic and stats
.PAL - Palette
.PL2 - Palette
.REV - NPC character graphics
.SPR - MAP tile graphics
Many of the files seem to have a 7 byte header:
The first 3 bytes seem to be a data type ID.
Bytes 4 and 5 are always zero.
Bytes 6 and 7 are a 16-bit length of the remaining data.
All integer values are 2-byte/16-bit little-endian.
The python dump script will create PNG images of all graphical assets,
and render all game maps (and all alternative versions) with four different
sets of information visualized. A few other things, like list files and
palettes will be prepared as well.
.ALT
====
This is a text file that describes alternate versions of a map with the
same filename. See .MAP for more information about the map data it modifies.
The file begins with one or more groups of alternatives that may be applied
to the map. It contains a flag condition that chooses to apply the alternate,
then two lists of as many map tile changes and NPC changes as needed.
` Back quote begins an alternate
26,1 Alternate applies when flag 26 is set to 1
15,17,0,25 Set X,Y=15,17 of tile layer (0) to 25
15,19,1,200 Set X,Y=15,19 of object layer (1) to 200
-1,-1,-1,-1 Four -1 values ends the list of tiles
3,1,4 Set NPC #3 property #1 to value 4
5,0,230 Set NPC #5 property #0 to value 230
-1,-1,-1 Three -1 values ends the list of NPCs
After the alternatives is a list of treasures for the map:
~ Tilde begins the treasure list
54,11,3000 At X,Y=54,11 is treasure 3000
21,2,3005 At X,Y=21,2 is treasure 3005
-1,-1,-1 Three -1 values ends the list of treasures.
END ALT The file ends with this line
Some maps have treasure but not alternates. These still have one alternate
block before the treasure list, but without a flag, which looks like this:
`
-1,-1,-1,-1
-1,-1,-1
.BLD
====
After a 7 byte header, this contains 64,000 (320x200) pixel bytes for
a VGA 13h image.
Some of the unused BLD files contain extra zeroes past the end of the file.
Attempting to use these seems to corrupt the music memory causing strange
sound.
.BSS
====
Boss graphic and stats.
All of the pixels in the graphic are inverted, and should be subtracted from
255 to recover the target value. Boss battles are initiated by .EVT scripts,
with the first 8 charaters of the boss name in the script used as the .BSS
filename.
7 byte header
4 bytes (2 16-bit integers, always: 800, 100)
10,000 (100x100) pixel bytes, main image
6 bytes (3 16-bit integers, always: 0, 800, 100)
10,000 (100x100) pixel bytes, black mask image
30 bytes (15 integers) monster stats
0: HP
1-13: Unknown
14: Experience
15: GP
.COL
====
Unknown. There is only one of these, named NIGHT2.COL.
The name NIGHT2 refers to the game's original title "The Endless Night 2".
7 byte header
4 bytes (2 16-bit integers: 191, 183)
.CNV
====
Simple conversation set associated with a .MAP with the same filename.
This associates a single line of dialog with an NPC character that will
trigger when talked to.
~5 Tilde followed by number indicates which NPC it applies to.
Hello! Text for the NPC to say.
Indiram Name given to the NPC.
Conversations are given in increasing numerical order. Numbers can be skipped.
The file ends with a single line of ~0, which is strange because it may
also begin with a ~0 with a valid conversation for NPC #0.
~0 Ends the file.
There seems to be some mechanism to add a fixed value to all conversation
indices in the file, allowing multiple sets of conversations to apply to the
map at different points in the story. FOREST1.MAP has conversations for
0,1,2,3 and later these get replaced by 4,5,6,7 if you return after defeating
Wekmog. Similarly TOWN1.MAP has conversations at 1,2,3... and another set at
40,41,42... but I have not determined how this is switched. I presume there is
some way to permanently offset a map's conversations with an .EVT script.
NPCs may be invisible, or placed on an inanimate object like a sign to surve
as a place to trigger these conversation events.
Conversation numbers 158, 159, and 160 designate a weapon shop, item shop,
and INN. See some of the ITOWN .CNV files for an example of how these should
be written.
YN$ can be used wherever the player's chosen name should appear.
.DWM
====
These are music files for the Diamondware Sound Toolkit.
A player program can be downloaded here:
https://archive.org/details/DiamondWaresSoundToolKit_1020
Each .MAP is associated with a music file that has a filename of its first
four characters. (E.g. this means FOREST1.MAP, FOREST2.MAP, FOREST3.MAP, etc.
will share FORE.DWM for their music.)
.EVT
====
These are a set of scripted events associated with a .MAP with the same
filename.
`300 Begins an event with number 300
END EVENT Ends an event
The event can be placed on the map by its number in the object layer.
Events seem to normally be given numbers in the 200-400 range,
usually starting from 300.
Event scripts have many commands, and I have not learned what they all do yet.
Generally a command is given on a single line, followed by 1 or more lines
that are its parameters. Here is a very incomplete list:
BC: the next line is the name of a boss. It will use the .BSS with the first
8 characters of the boss name.
PM: triggers a dialog. The next 2 lines are text and the speaker name.
EN: Create an NPC, followed by 5 lines: X, Y, NPC#, NPC sprite set, direction
CW: next line is the name of a new map to load
FS: next line is the number of a flag value to set.
Two values separated by a comma will set that flag to a specific value.
GET: next line is the number of an item to receive.
.FNT
====
Font file.
7 byte header
127 8x8 images:
4 bytes (64, 8)
64 (8x8) pixel bytes
.GFX
====
Magic or special attack graphics tiles.
These files are indexed by in MAGIC.LST.
The animation and use of these tiles is likely hard-coded in the executable.
7 byte header.
Series of images:
4 bytes (20, 20)
400 (20x20) pixel bytes
.LST
====
There are several .LST files but there are a few different formats.
Some of them contain "encrypted" text, which means that the ASCII values
of the characters on the encrypted lines (not including the CR/LF) are
incremented by 1.
ITEM.LST
A list of items.
Each entry starts with ~ and an index number.
After this are 3 encrypted lines containing the name, a blank line, and a
number (unknown purpose).
Ends with ~0.
MAGIC.LST
A list of magic and special attacks.
Each entry starts with ~ and an index number.
After this are encrypted lines containing the name of the attack, a description
and some stats.
Each magic name corresponds directly to a .GFX file.
Ends with ~0.
MONSTER.LST
A list of monster names. The first line will be indexed as monster 1.
The first 8 characters of a monster name will be used to select its .MON file.
OBJNAME.LST
A list of .REV files (without extension). The first line is index 1.
These provide NPC graphics for a .MAP.
PLACE.LST
Each entry starts with ~ and an index number.
The name of a town follows, its .MAP filename, and an entry X,Y location.
Ends with a ~0.
Not certain what this is used for in the game.
RARE.LST
A list of rare items. Unlike the other item lists this is not encrypted.
Each entry starts with ~ and an index number, then has one line for its name.
Ends with ~0.
RING.LST
A list of ring items.
Each entry starts with ~ and an index number.
Three encrypted lines follow, containing the name and some stats.
SHIELD.LST
A list of shield items.
Each entry starts with ~ and an index number.
Three encrypted lines follow, containing the name and some stats.
SPRNAME.LST
A list of .SPR files (without extension). The first line is index 1.
These provide tile graphics for a .MAP.
TREASURE.LST
WEAK.LST
I don't know what this contains. It's just a list of numbers, mostly negative?
WEAPON.LST
A list of weapon items.
Each entry starts with ~ and an index number.
Three encrypted lines follow, containing the name and some stats.
WRLDNAME.LST
A list of .MAP files (without extension). The first line is index 1.
These provide indexes for .MAP files to reference each other for connections
and teleports.
.MAN
====
These contain graphics used for the player during battle.
There are only three of these. For some reason COMBAT.MAN and ICON.MAN have
an extra two zero bytes before the first image, but DEATH.MAN does not.
All of the pixels in the graphic are inverted, and should be subtracted from
255 to recover the target value.
7 byte header.
4 bytes (240, 30)
2 bytes extra (0), not included in DEATH.MAN
900 (30x30) pixel bytes, first image
Remaining images:
4 bytes (240, 30)
900 (30x30) pixel bytes
.MAP
====
These are the maps the player spends most of their time in the game walking
around. These are indexed by WRLDNAME.LST.
7 byte header
8576 bytes (67x64 2-byte integers) tile (layer 0) data
8576 bytes (67x64 2-byte integers) object (layer 1) data
The map is actually 64x64 tiles in size (each tile is 20x20 pixels),
but on the end of each row is 3 more 2-byte integers which are used to
store various data.
Coordinate (X,Y,L) will refer to an integer in this data grid.
If X is 64-66 it refers to the extra data stored at the end of a row.
Each map optionally have several files associated by filename:
.CNV - simple conversations
.EVT - event scripts
.ALT - alternate versions
.DWM - music (takes the first 4 characters of map filename only)
.REV index at (64,4,1) selects NPC graphics from OBJNAME.LST.
.SPR index at (64,5,1) selects tile graphics from SPRNAME.LST.
Right adjacent map at (64,0,1) selected from WRLDNAME.LST. 0 for none.
Down adjacent map at (64,1,1) selected from WRLDNAME.LST. 0 for none.
Left adjacent map at (64,2,1) selected from WRLDNAME.LST. 0 for none.
Up adjacent map at (64,3,1) selected from WRLDNAME.LST. 0 for none.
Ten monsters at (64,6-15,1) selected from MONSTER.LST. 0 is the same as 1?
Layer 0: (0-63,0-63,0)
A tile number of 0-159 will reference the map's associated tile set (.SPR),
and 160-319 place a tile 0 with an overlapping masked tile that will render
above the player and NPCs. The overlapping tile uses the number-160 as
index, and colour 0 is treated as transparent.
Layer 1: (0-63,0-63,1)
This layer contains object numbers that place various objects you can
interact with:
157: No Monsters (placed in top left corner)
158: Weapon shop, text from .CNV
159: Item shop, text from .CNV
160: Inn, text from .CNV
2000-2061: Teleport, data is 3 bytes found at coordinate Y = object-2000
(64,Y+0,0) target map WRLDNAME.LST index
(64,Y+1,0) target X location
(64,Y+2,0) target Y location
3000-3099: Contains a treasure from TREASURE.LST (must appear in .ALT too)
200-399: Trigger an event script from .EVT
There are a few other values here which are still mysterious.
Low numbers like 1 or 6 frequently appear in the bottom left or top
right corners. FOREST1.MAP places 1,2,3,4 on a group of fairies, but I
am not sure if they have any function.
NPCs:
Each map can have up to 30 active NPCs. These will deliver simple dialogue
text from the .CNV file when talked to. They may be invisible and placed
on inanimate objects (e.g. a signpost), and they may optionally walk around
randomly. Each NPC takes up 7 values in a column past the end of a row.
# 0 (65, 0- 6,0) NPCs 0-8 in layer 0 column 65
# 1 (65, 7-13,0)
# 2 (65,14-20,0)
...
# 8 (65,56-62,0)
# 9 (65, 0- 6,1) NPCs 9-17 in layer 1 column 65
...
#17 (65,56-62,1)
#18 (66, 0- 6,0) NPCs 18-27 in layer 0 column 66
...
#26 (66,56-62,0)
#27 (66, 0- 6,1) NPCs 27-29 in layer 0 column 66
#28 (66, 7-13,1)
#29 (66,14-20,1)
Each NPC has 7 points of associated data:
0: pixel X coordinate (map grid location * 20)
1: pixel Y coordinate
2: facing direcation 1,2,3,4 = U,R,D,L (0 = none)
3: -2 = no NPC, -1 = stationary, 0 = random walking
4: always 0?
5: always 0?
6: sprite index
The sprite index selects a row of sprites from the associated .REV file.
Each character has 12 sprites, so this index can be multipled by 12 to
find that character's first sprite graphic.
Of the 12 sprites, each character has 3 sprites for each direction, 1 for
stationary, and 2 walking sprites. For some stationary NPCs (see dead
bodies in SENTRY.REV) the facing direction might be used to select one of
the 4 stationary sprites to use.
.MON
====
Monster graphic images and stats.
These are exactly the same as .BSS but the image dimension is 50x50 instead
of 100x100. The monster name will come from MONSTER.LST, and the first 8
letters of the name become its .MON filename. Monsters are selected by their
index in MONSTER.LST.
Like with .BSS, all of the pixels in the graphic are inverted, and should be
subtracted from 255 to recover the target value.
7 byte header
4 bytes (2 16-bit integers, always: 400, 50)
2500 (50x50) pixel bytes, main image
6 bytes (3 16-bit integers, always: 0, 400, 50)
2500 (50x50) pixel bytes, black mask image
30 bytes (15 integers) monster stats
0: HP
1-13: Unknown
14: Experience
15: GP
.PAL
====
Palettes for the game. Not sure if this is used, or if .PL2 is used instead,
because there are two otherwise identical sets provided.
7 byte header
4 byte entries:
Red: 0-63
Green: 0-63
Blue: 0-63
Unused: 0
.PL2
====
The same as .PAL but with 3-byte RGB entries instead.
7 byte header
3 byte entries:
Red: 0-63
Green: 0-63
Blue: 0-63
.REV
====
Graphics to use for NPCs on a .MAP.
These are indexed by OBJNAME.LST.
7 byte header.
Series of images:
4 bytes (20, 20)
400 (20x20) pixel bytes
When the 4 byte prefix to an image is all zeroes, this marks the end of
image data in the file.
OLIVER.REV contains the player character sprites used while walking on maps.
DEADDRAG.REV appears to be a .SPR file with the wrong extension.
It does not appear to be used by the game.
.SPR
====
Graphics tiles to use for the .MAP.
These are indexd by SPRNAME.LST.
7 byte header.
Series of images (up to 158)
4 bytes (20, 20)
400 (20x20) pixel bytes
When the 4 byte prefix to an image is all zeroes, this marks the end of
image data in the file.
After the image data, there is an additional suffix containing collision
and animation information.
At file location $FA07 is a table of 320 bytes (160 integers). A value of 1
indicates that a tile is solid (stops the player), and a value of 0 indicates
that it is empty.
At file location $FB47 is another table of 320 bytes (160 integers). A value
of 1 indicates the the tile is animated, and will flip through a series of
six consecutive images when shown in the game.
Aspetra data format notes
Brad Smith, 2019-08-09
Brad Smith, 2021-05-03 - monster 0 is valid, object 157 disables monsters
http://rainwarrior.ca
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment