Build Capital Quest for CoCo 3 with maps and emulator tests

This commit is contained in:
Brian Holman committed 2026-11-09 18:44:56 -07:00
commit 9f7d6f4786
44 files changed
+6238

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
// autostart.c - Program to be run by the Disk Basic DOS command.
// By Pierre Sarrazin <http://sarrazip.com/>
// This file is in the public domain.
//
// Reference: Roger Schrag, "A Special Use For The DOS Command",
// The Rainbow, November 1984, p.140.
//
// To compile: cmoc --dos autostart.c
// To install on CoCo disk image: install-coco-boot-loader some-image.dsk autostart.bin
// To run on CoCo: type DOS and <Enter> to automatically run *.BAS.
//
// Version history:
// 0.1.0 2016-01-16 Use of legacy.h discontinued.
// 0.1.1 2018-04-03 Adapted to CMOC 0.1.51 (const).
// 0.1.2 2018-08-04 Basic line now forced to be uppercase.
#include <coco.h>
#define ENDFLG 0x00 // STOP/END FLAG: POSITIVE=STOP, NEG=END
#define CINBFL 0x70 // CONSOLE IN BUFFER FLAG: 00=NOT EMPTY, $FF=EMPTY
#define RVEC4 0x016a // RAM hook: CONSOLE IN
byte oldHookInstr;
word oldHookAddr;
const char *basicLineReader;
void feedKeyboard()
{
word preservedX;
asm("stx", preservedX);
* (byte *) CINBFL = 0; // console in buffer now not empty
char nextBasicByte = (char) toupper(*basicLineReader);
++basicLineReader;
if (nextBasicByte == 0) // if finished returning Basic line
{
* (byte *) RVEC4 = oldHookInstr;
* (word *) (RVEC4 + 1) = oldHookAddr;
nextBasicByte = '\r'; // return "Enter" to cause execution of Basic line
}
asm {
lda nextBasicByte
ldx preservedX
tfr u,s // discard stack frame
leas 4,s // discard previous stack frame ptr and return addr
rts // return to caller of CONSOLE IN
}
}
int main()
{
* (byte *) ENDFLG = - * (byte *) ENDFLG; // why?
basicLineReader =
#ifndef COMMAND
"LOADM\"CAPITAL.BIN\":EXEC"
#else
COMMAND
#endif
;
// Install function feedKeyboard() as CONSOLE IN routine.
//
oldHookInstr = * (byte *) RVEC4;
oldHookAddr = * (word *) (RVEC4 + 1);
* (byte *) RVEC4 = 0x7e; // JMP instruction
* (void **) (RVEC4 + 1) = (void *) feedKeyboard;
* (word *) 0x2600 = 0; // overwrite "OS" marker with two null bytes
asm("jmp", "$ac7c"); // go interpret a Basic line (which is read from console)
return 0; // avoids compiler error
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Offline, standard-library-only conversion of Fla-shop SVG to identity spans.
SVG input uses straight M/L/H/V/Z paths. Flood regions at original resolution,
associate via documented seed coordinates, then reduce to guest scanline runs.
"""
import collections, json, math, re, struct, zlib
from pathlib import Path
import xml.etree.ElementTree as ET
ROOT=Path(__file__).resolve().parents[1]
W,H=2000,1200
rows=[s.split('\t') for s in (ROOT/'data/states.tsv').read_text().splitlines()]
assert len(rows)==50 and len({r[0] for r in rows})==50
ids={r[0]:i+1 for i,r in enumerate(rows)}
def paths(d):
tok=re.findall(r'[MmLlHhVvZz]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?',d)
i=0;x=y=0;out=[];poly=[];cmd=''
while i<len(tok):
if tok[i].isalpha():
cmd=tok[i];i+=1
if cmd.lower()=='z':
if poly:poly.append(poly[0]);out.append(poly);poly=[]
x,y=out[-1][0];continue
rel=cmd.islower(); c=cmd.lower()
if c in ('m','l'):
a,b=map(float,tok[i:i+2]);i+=2
x,y=(x+a,y+b) if rel else (a,b)
if c=='m':
if poly:out.append(poly)
poly=[];cmd='l' if rel else 'L'
elif c=='h':
a=float(tok[i]);i+=1;x=x+a if rel else a
elif c=='v':
a=float(tok[i]);i+=1;y=y+a if rel else a
else:raise ValueError(cmd)
poly.append((x,y))
if poly:out.append(poly)
return out
def png(path,w,h,pixels):
def chunk(tag,b):return struct.pack('>I',len(b))+tag+b+struct.pack('>I',zlib.crc32(tag+b)&0xffffffff)
raw=b''.join(b'\0'+bytes(pixels[y*w*3:(y+1)*w*3]) for y in range(h))
path.write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0))+chunk(b'IDAT',zlib.compress(raw))+chunk(b'IEND',b''))
def main():
svg=ET.parse(ROOT/'assets/usa-source.svg').getroot()
elements={e.get('id'):e for e in svg.iter() if e.get('id')}
land=bytearray(W*H)
outline=paths(elements['outline'].get('d'))
# Even-odd scan conversion, coast and islands included.
edges=[(a,b) for p in outline for a,b in zip(p,p[1:]+p[:1]) if a[1]!=b[1]]
buckets=[[] for _ in range(H)]
for a,b in edges:
for y in range(max(0,math.ceil(min(a[1],b[1])-.5)),min(H,math.ceil(max(a[1],b[1])-.5))):
buckets[y].append(a[0]+(y+.5-a[1])*(b[0]-a[0])/(b[1]-a[1]))
for y,xx in enumerate(buckets):
xx.sort()
for a,b in zip(xx[::2],xx[1::2]):
lo=max(0,math.ceil(a-.5));hi=min(W,math.ceil(b-.5));land[y*W+lo:y*W+hi]=b'\1'*(hi-lo)
coast=bytearray(land)
# Three-source-pixel boundary stroke closes subpixel joins before flood fill.
for p in paths(elements['boundaries'].get('d')):
for a,b in zip(p,p[1:]):
n=max(1,math.ceil(max(abs(a[0]-b[0]),abs(a[1]-b[1]))))
for t in range(n+1):
x=round(a[0]+(b[0]-a[0])*t/n);y=round(a[1]+(b[1]-a[1])*t/n)
for yy in range(max(0,y-1),min(H,y+2)):
for xx in range(max(0,x-1),min(W,x+2)):land[yy*W+xx]=0
seeds={}
for e in svg.iter():
if e.tag.endswith('tspan'):
ab=''.join(e.itertext()).strip()
if ab in ids:seeds[ab]=[round(float(e.get('x'))+15),round(float(e.get('y'))-12)]
# Callout labels are outside their states. Seed actual interiors instead.
seeds.update({'VT':[1800,280],'NH':[1840,280],'MA':[1830,340],
'RI':[1860,360],'CT':[1830,375],'NJ':[1780,440],
'DE':[1764,509],'MD':[1720,536],'HI':[739,1130]})
seeds.update(json.loads((ROOT/'data/map-seeds.json').read_text()) if (ROOT/'data/map-seeds.json').exists() else {})
owners=bytearray(W*H);claimed={};counts={}
for ab,(x,y) in seeds.items():
start=y*W+x
assert land[start],(ab,'seed is not land',x,y)
assert not owners[start],(ab,'shares component with',claimed.get(owners[start]))
val=ids[ab];q=collections.deque([start]);owners[start]=val;count=0
while q:
pos=q.popleft();count+=1
for nxt in (pos-1,pos+1,pos-W,pos+W):
if 0<=nxt<W*H and land[nxt] and not owners[nxt]:owners[nxt]=val;q.append(nxt)
claimed[val]=ab;counts[ab]=count
# Unassigned land = detached islands/peninsulas: nearest assigned land,
# using a multi-source breadth-first expansion. The source mask is retained.
q=collections.deque(i for i in range(W*H) if owners[i] and any(0<=j<W*H and not owners[j] for j in (i-1,i+1,i-W,i+W)))
nearest=bytearray(owners)
while q:
p=q.popleft()
for n in (p-1,p+1,p-W,p+W):
if 0<=n<W*H and not nearest[n]:nearest[n]=nearest[p];q.append(n)
# Detached Alaska/Hawaii insets and Michigan's Upper Peninsula require
# explicit geographic regions; proximity alone mislabels small islands.
# Assign detached original land by geographic ownership, not nearest capital.
for y in range(H):
for x in range(W):
p=y*W+x
if land[p] and not owners[p]:
if 560<x<785 and 995<y<1190:owners[p]=ids['HI']
elif x<560 and y>850:owners[p]=ids['AK']
elif 1175<x<1465 and 180<y<330:owners[p]=ids['MI']
else:owners[p]=nearest[p]
(ROOT/'data/map-seeds.json').write_text(json.dumps(seeds,indent=2)+'\n')
# Three views share the exact same owner raster and stable TSV IDs.
views=[('national',200,110,(0,0,2000,1200)),('northeast',100,100,(1540,120,1980,600)),('hawaii',100,82,(565,995,785,1185))]
arrays=[];meta={};preview=[]
for name,w,h,(x0,y0,x1,y1) in views:
small=[]
for y in range(h):
for x in range(w):
# Sample one source interior pixel at pixel center.
sx=int(x0+(x+.5)*(x1-x0)/w);sy=int(y0+(y+.5)*(y1-y0)/h)
small.append((owners[sy*W+sx] or nearest[sy*W+sx]) if coast[sy*W+sx] else 0)
if name=='hawaii':small=[v if v==ids['HI'] else 0 for v in small]
# One guest pixel on the right/bottom of a state seam gives continuous
# boundaries. Preserve tiny national masks; their detail view is larger.
areas=collections.Counter(small); original=small[:]
for y in range(h-1):
for x in range(w-1):
pos=y*w+x;val=original[pos]
if val and areas[val]>8 and any(v and v!=val for v in (original[pos+1],original[pos+w])):
small[pos]=0
spans=[];offsets=[0];total={}
for val in range(1,51):
cnt=0
for y in range(h):
x=0
while x<w:
if small[y*w+x]!=val:x+=1;continue
first=x
while x<w and small[y*w+x]==val:x+=1
spans.extend([y,first,x-first]);cnt+=x-first
offsets.append(len(spans));total[rows[val-1][0]]=cnt
arrays.append('const unsigned char '+name+'_spans[] = {\n'+','.join(map(str,spans))+'\n};\nconst unsigned '+name+'_offsets[51] = {'+','.join(map(str,offsets))+'};\n')
meta[name]={'size':[w,h],'source_box':[x0,y0,x1,y1],'pixels':total,'bytes':len(spans)+102}
colors=[(8,19,34),(44,116,142),(73,145,155),(103,164,169),(36,92,126)]
pix=[]
for v in small:pix.extend(colors[0] if not v else colors[1+(v%4)])
png(ROOT/'assets'/('preview-'+name+'.png'),w,h,pix)
assert all(meta['national']['pixels'].values()),meta['national']['pixels']
(ROOT/'src/map_data.c').write_text('/* Adapted from Fla-shop.com, CC BY 4.0. See assets/CREDITS.md. */\n#include "map_data.h"\n'+''.join(arrays))
(ROOT/'include/map_data.h').write_text('\n'.join('extern const unsigned char '+n+'_spans[];\nextern const unsigned '+n+'_offsets[51];' for n,*_ in views)+'\n')
(ROOT/'src/states.c').write_text('#include "quiz.h"\nconst State states[50] = {\n'+',\n'.join('{"%s","%s","%s"}'%tuple(r) for r in rows)+'\n};\n')
meta['source_component_pixels']=counts
(ROOT/'data/map-manifest.json').write_text(json.dumps(meta,indent=2)+'\n')
print({n:meta[n]['bytes'] for n,*_ in views})
if __name__=='__main__':main()
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Minimal JSON-RPC client for the installed VCC Plus public STDIO MCP helper.
No grant contents, private sockets, memory mutation or emulator internals used.
"""
import json,subprocess,sys
from pathlib import Path
HELPER='/Applications/VCC Plus.app/Contents/Helpers/vcc-mcp'
class VCC:
def __init__(self,grant):
self.p=subprocess.Popen([HELPER,'--grant',str(grant)],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,bufsize=1)
self.i=0
self.request('initialize',{'protocolVersion':'2024-11-05','capabilities':{},'clientInfo':{'name':'capital-quiz-tests','version':'1.0'}})
self.p.stdin.write(json.dumps({'jsonrpc':'2.0','method':'notifications/initialized'})+'\n');self.p.stdin.flush()
def request(self,method,params):
self.i+=1
self.p.stdin.write(json.dumps({'jsonrpc':'2.0','id':self.i,'method':method,'params':params})+'\n');self.p.stdin.flush()
while True:
line=self.p.stdout.readline()
if not line:raise RuntimeError(self.p.stderr.read())
reply=json.loads(line)
if reply.get('id')==self.i:
if 'error' in reply:raise RuntimeError(reply['error'])
return reply['result']
def call(self,name,args=None):
r=self.request('tools/call',{'name':name,'arguments':args or {}})
for b in r.get('content',[]):
if b.get('type')=='text':
try:return json.loads(b['text'])
except ValueError:pass
return r
def close(self):
self.p.stdin.close();self.p.terminate();self.p.wait()
if __name__=='__main__':
v=VCC(sys.argv[1])
try:
if len(sys.argv)<3:result=v.request('tools/list',{})
else:result=v.call(sys.argv[2],json.loads(sys.argv[3]) if len(sys.argv)>3 else {})
print(json.dumps(result,indent=2))
finally:v.close()