Last active
May 4, 2021 02:20
-
-
Save bbbradsmith/864232d2bcbda59ce7c9625747448218 to your computer and use it in GitHub Desktop.
Aspetra (DOS) data file formats and python dump script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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