Document DM-PR-001, Revision A. November 1995.
For licensed Maestro developers. Distribute with cartridge dev kits only.
Maestro cartridges run a single Lua program on the Toccata CPU.
At boot, the BIOS exposes four global tables —
gfx, snd,
inp, and sys —
which together form the entire programmer interface to the machine.
There is no other way to talk to the hardware. There is no operating
system underneath.
The frame rate is 60 Hz. The display is 384 by 224 pixels at 8 bits per pixel, indexed into a 256-entry palette drawn from the 18-bit master colour space. All coordinates are in screen pixels; the origin is the top-left.
This document is the four-page reference card that ships with every cartridge dev kit, expanded for the on-line edition.
A cart is a directory under public/carts/
containing two files:
manifest.json — metadata.main.lua — the program.The manifest fields are:
{
"id": "MY GAME",
"title": "MY GAME",
"author": "YOUR NAME",
"year": 1995,
"main": "main.lua",
"description": "One sentence. The BIOS shows this on the cart screen."
}
Once the cart directory exists, register it in
public/carts/index.json so the BIOS lists it:
[
{ "id": "SWORDFALL", "path": "swordfall" },
{ "id": "MY GAME", "path": "my-game" }
]
id is what appears on the cart-select
screen. Keep it short; long titles wrap badly on the licensed Maestro
Vision monitor.
The BIOS calls three global functions, in order, for every cart it loads:
_init() |
Called once, after the cart is loaded into RAM. Use it to define sprite sheets and tilemaps, seed the random number generator, and set initial state. |
_update() |
Called once per frame, before _draw.
Read input here. Step the simulation. |
_draw() |
Called once per frame, after _update.
Issue draw calls here. The framebuffer is presented when this
function returns. |
All three are optional, but a cart that does not define
_draw will display whatever was on screen
when the BIOS handed off, which is rarely what the author intended.
The Toccata's interpreter card runs Lua 5.4. The standard library is present, with the cuts you would expect from a console with no file system and no clock the cart is allowed to see.
math.*, string.*,
table.* — in full.coroutine.* — in full. Carts use
coroutines for cutscenes and scripted enemies; see section 10.pcall, xpcall,
error, assert.tostring, tonumber,
ipairs, pairs,
select, type.print — equivalent to
sys.print; output is to the host console
and never seen by players.io.*, os.*
— the cart cannot reach the host file system or clock. For
elapsed time use sys.time().require, dofile,
loadfile — carts ship as a single
source file. If you want modules, declare them as nested tables in
main.lua.debug.* — sealed off.gfx,
snd, inp, or
sys. The BIOS will overwrite them if you do,
but inconsistently across boot revisions.
gfx table
The Cadenza graphics unit. All draw calls write to a
software framebuffer; the framebuffer is presented at the end of
_draw().
| gfx.W, gfx.H | Screen dimensions. 384 and 224. |
| gfx.cls(c) | Clear the framebuffer to palette index c. |
| gfx.pset(x, y, c) | Plot one pixel. |
| gfx.line(x0, y0, x1, y1, c) | Draw a line. Bresenham, clipped. |
| gfx.rect(x, y, w, h, c) | Filled rectangle. |
| gfx.tri(x0, y0, x1, y1, x2, y2, c) | Filled flat-shaded triangle. Counts against the polygon budget (~1500 triangles per frame on real hardware). |
| gfx.text(s, x, y, c) | Draw a string in the BIOS font. The font is 8 by 8, uppercase, fixed-width. Lower case is folded to upper. |
Cadenza holds 256 palette entries. The first 96 are populated at boot; the remaining 160 are zeroed and available for cart authors to define. Index 0 is reserved as transparent for sprites and the affine layer.
| Range | Ramp | Notes |
|---|---|---|
| 0..15 | Neutral grey | 0 = black; 15 = white. The default text colour. |
| 16..31 | Iron | Cool blue-grey. The sword blade ramp from SWORDFALL. |
| 32..47 | Brass / gold | Warm. Hilts, the company crest, HUD trim. |
| 48..63 | Crimson | Damocles brand red. 48 = darkest, 63 = brightest. |
| 64..79 | Indigo | The BIOS background tint. |
| 80..95 | Phosphor green | Old monitor green; legacy readout colour. |
| 96..255 | User | Black at boot. Define with gfx.pal. |
The boot palette is reset between carts. Carts that need a "hard black" they can blit (say, for shadows) should define one of the user slots to be black and use that index, since index 0 is the sprite transparency colour.
| gfx.pal(i, r, g, b) | Set palette entry i (0..255). Each component is 0..63 (the native 18-bit master palette). Takes effect on the next draw call. |
| gfx.pal_ramp(start, count, r0,g0,b0, r1,g1,b1) | Fill count palette entries beginning at start, linearly interpolated between two endpoints. Useful for water gradients, day/night skies, sprite shadow ramps. |
| gfx.pal_cycle(start, count, amount) | Rotate count palette entries beginning at start by amount slots, in place. Per-frame palette cycling is the canonical 1990s trick for water shimmer, lava flow, conveyor belts, candle flicker. |
Sprite sheets are uploaded to VRAM as a single hex string of palette indices. Each pixel is two hex digits (00..FF). Pixels are in row-major order: top row left-to-right, then the next row, and so on. Whitespace in the string is stripped, so multi-line layouts are legal:
-- A 4 x 2 sheet cut as a single 4 x 2 tile, -- a "+" shape in palette index 15 over palette index 4. gfx.spr_sheet(1, 4, 2, 4, 2, "0F0F" .. -- row 0: index 0, 15, 0, 15 (transparent / white / transparent / white) "FFFF" -- row 1: solid index 255 (you'd typically use real colours here) )
Tile indices into a sheet are zero-based; the
gfx.spr call wants column and row.
Tilemaps reference tiles by 1-based index (0 means empty / skipped):
the entry at flat index i in
gfx.map_def is shown at column
((i-1) % cols), row
floor((i-1) / cols).
string.format.
See SWATTER for a worked example.
| gfx.spr_sheet(id, w, h, tw, th, hex) | Define sprite sheet id. The sheet is w by h pixels, divided into tiles of tw by th. |
| gfx.spr(id, col, row, x, y, flipX, flipY, scale) | Draw the tile at column col, row row of sheet id at screen position x,y. flipX, flipY default to false. scale is an integer multiplier 1..4. Palette index 0 is treated as transparent. |
| gfx.map_def(mapId, sheetId, cols, rows, tiles) | Define a tilemap. tiles is a Lua table of length cols * rows; each entry is a 1-based tile index into the sheet, or 0 for empty. |
| gfx.map_draw(mapId, scrollX, scrollY, dx, dy) | Draw a tilemap to the framebuffer with integer scrolling. |
| gfx.map_aff(mapId, a, b, c, d, tx, ty, horizonY) | Draw a tilemap through a 2×2 affine matrix with translation. Pixels above horizonY are skipped. See section 5.7 below. |
For each screen pixel (sx, sy) the affine layer samples the tilemap at world coordinate
wx = a*sx + b*sy + tx wy = c*sx + d*sy + ty
The matrix lets you rotate, scale, and shear. horizonY is an integer in screen pixels; rows above it are skipped, leaving room to draw a sky.
A rotated, zoomed ground plane viewed from a camera at world (cx, cy) looking along angle ang with screen origin (scx, scy):
local ca, sa = math.cos(ang), math.sin(ang) local z = zoom local a, b = ca/z, sa/z local c, d = -sa/z, ca/z local tx = cx - (a*scx + b*scy) local ty = cy - (c*scx + d*scy) gfx.map_aff(1, a, b, c, d, tx, ty, horizonY)
Per-scanline matrices (true Mode-7 with horizon tilt) are not yet exposed; carts should not depend on the appearance of fixed-altitude flat ground for too long.
Two whole-screen post-processing registers, applied at flip time between the framebuffer and the monitor.
| gfx.fade(amount) | Multiply screen brightness by
(1−amount). 0 = no fade,
1 = solid black. Implemented as a
single-LUT multiply on the palette resolve, so it is essentially
free per frame. Use for transitions, cinematic dim-downs, and
pause overlays. |
| gfx.mosaic(size) | Block-replicate the framebuffer in
size×size chunks. 0
or 1 = off; up to 16 = chunky. Mirrors
the SNES MOSAIC register; carts use it for "data corruption"
stings, scene transitions, and the Maestro's signature
screen-wipe-as-zoom. |
gfx.fade(0) and
gfx.mosaic(0) at the top of
_update, then set them again as needed.
The most outlandish thing the Cadenza will let you do. The HBlank channel is a per-scanline programmable interrupt: as the beam sweeps from the top to the bottom of the screen, the cart is given a chance to mutate the palette (or anything else) once per row. Carts use it for sky gradients, water reflection bands, raster bars, parallax horizons — anything that wants different screen content at different vertical positions without a tilemap.
| gfx.hpal(palIdx, y, r, g, b) | Schedule: at scanline y, set palette entry palIdx to (r, g, b). Components are 0..63. Events are queued; flushed at flip time; cleared each frame. Carts call this every frame. |
| gfx.hpal_ramp(palIdx, y0, y1, r0,g0,b0, r1,g1,b1) | Convenience: schedule a linear ramp of one
palette index across a Y range. One hpal
event per scanline in [y0, y1]. |
| gfx.hblank(fn or nil) | Register a Lua callback invoked once per
scanline at flip time, given the scanline y. The
callback may mutate palette, fade, or mosaic. Pass
nil to clear. Persists until cleared
(unlike hpal, which clears each frame). |
Build a sky gradient like this:
function _draw()
-- Fill the sky region with a single index whose RGB we'll vary per row.
gfx.rect(0, 0, gfx.W, 130, 64)
gfx.hpal_ramp(64, 0, 129,
4, 2, 18, -- top: deep indigo
30, 12, 28) -- bottom: dusk magenta
-- ...rest of the scene...
end
hpal is essentially free (the engine
rebuilds only the affected LUT entries). The Lua callback form is
much more expensive: each scanline crosses the JS / Lua boundary,
so a callback that does nontrivial work will halve your frame
rate. Prefer hpal / hpal_ramp
for routine gradients; reserve hblank for
effects that genuinely need it.
| gfx.collide(x1,y1,w1,h1, x2,y2,w2,h2) | True if two screen-space axis-aligned rectangles overlap. The Cadenza's old "hit register"; expose your own broad-phase if you need anything cleverer. |
snd tableThe Aria sound chip. Sixteen PCM channels, four FM operators, one noise channel. Channels are addressed by integer index; addressing the same channel twice cuts off the previous voice.
| snd.fm(ch, freq, durSec, modRatio, modIndex, gain) | Trigger an FM voice on channel ch at freq Hz for durSec seconds. modRatio and modIndex control the modulator. gain is 0..1. Defaults: ratio 2.0, index 3.0, gain 0.25. |
| snd.noise(ch, durSec, gain, cutoffHz) | Trigger a band-limited noise burst. Defaults: gain 0.2, cutoff 1000. |
| snd.sample_def(id, hex, rate) | Define PCM sample id. hex is signed 8-bit samples encoded as two-character hex; rate is the base sample rate in Hz. |
| snd.play(ch, sampleId, pitch, gain, loop) | Play a defined sample. pitch is a multiplier on the base rate (1.0 = original). Defaults: pitch 1.0, gain 0.5, loop false. |
| snd.stop(ch) | Silence one channel. |
| snd.mute_all() | Silence every channel. Polite carts call this on pause. |
The Aria's FM voices are two-operator: a modulator drives a carrier. Four parameters matter:
Starter recipes:
| Sound | freq | modRatio | modIndex | gain | dur |
|---|---|---|---|---|---|
| Soft sine note | 440 | 1.0 | 0.5 | 0.20 | 0.25 |
| Bright pluck | 660 | 2.0 | 4.0 | 0.22 | 0.10 |
| Wood block | 880 | 3.0 | 3.5 | 0.20 | 0.05 |
| Bell | 880 | 3.5 | 5.0 | 0.18 | 0.6 |
| Bass blip | 110 | 2.0 | 2.0 | 0.30 | 0.15 |
| UI tick | 1760 | 1.0 | 0.4 | 0.10 | 0.03 |
Triggering a new voice on a channel that's already playing cuts off the previous voice. For chords use distinct channels: SWORDFALL's D-minor stinger uses channels 0, 1, 2 in parallel.
The Aria's most contentious feature, included over engineering's objections after a marketing demo at ECTS '95. SPEAK is a two-formant phoneme synthesiser in the lineage of the TI Speak & Spell and Atari SAM. It does not do text-to-speech. Carts hand it phonemes; SPEAK strings them together.
| snd.say(phonemes, pitch, gain) | Synthesise speech from a phoneme string. phonemes is hyphen- or whitespace-separated codes (see below). pitch is the voiced fundamental in Hz; 110 sounds adult-male, 220 sounds adult-female, 80 sounds like the announcer in Damocles Brawl. gain is 0..1. |
The phoneme alphabet:
| Code | Approximate sound | Code | Approximate sound |
|---|---|---|---|
| AA | "a" in father | B | "b" in boy |
| AE | "a" in cat | D | "d" in dog |
| AH | "u" in cup | G | "g" in go |
| AO | "a" in saw | L | "l" in let |
| AW | "ow" in now | M | "m" |
| AY | "i" in eye | N | "n" |
| EE | "ee" in see | NG | "ng" in sing |
| EH | "e" in red | R | "r" |
| ER | "er" in her | V | "v" |
| IH | "i" in sit | W | "w" |
| OW | "o" in so | Y | "y" in yes |
| OY | "oy" in boy | Z | "z" |
| UH | "oo" in book | F | "f" |
| UW | "oo" in you | HH | "h" |
| CH | "ch" in church | K | "k" |
| JH | "j" in jump | P | "p" |
| SH | "sh" in she | S | "s" |
| TH | "th" in thin | T | "t" |
| ZH | "s" in measure | _ | silence (about 80 ms) |
Worked examples:
snd.say("HH-AH-L-OW M-AE-S-T-R-OW", 120) -- "hello, Maestro"
snd.say("AH-OW R-OW R-AH", 100) -- "aurora"
snd.say("D-AE M-AH K-L-IH Z", 90) -- "Damocles" (rough)
snd.say("F-AY R IH N _ DH-AH _ HH-OW L", 110) -- "fire in the hole" (sort of)
inp tableInput from the Scepter controller. Buttons are referenced by their lowercase name as a string:
"up", "down",
"left", "right",
"a", "b",
"c", "d",
"l", "r",
"start", "select".
| inp.btn(name) | True while the button is held. |
| inp.btnp(name) | True only on the frame the button was pressed. Use this for menu navigation. |
| inp.btnr(name) | True only on the frame the button was released. |
inp.btn for in-game purposes, but the
eject behaviour cannot be suppressed.
The Maestro Pointer is a trackball-style two-button mouse, plugged into the auxiliary port on the back of rev D and later units. The emulator reports the host mouse as the Pointer.
Mouse button names are the strings
"left" and
"right". The Pointer is considered
active once the player has moved or clicked it; before then,
position queries return -1. Carts that
require the Pointer should display a "REQUIRES MAESTRO POINTER"
notice on their title screen.
| inp.mouse_x() | Pointer X in screen pixels (0..383), or
-1 if the Pointer has not been used
this session. |
| inp.mouse_y() | Pointer Y in screen pixels (0..223), or
-1. |
| inp.mouse_active() | True once the Pointer has reported any motion or click this session. |
| inp.mouse_btn(name) | True while the named mouse button is held. |
| inp.mouse_btnp(name) | True only on the frame the mouse button was pressed. |
| inp.mouse_btnr(name) | True only on the frame the mouse button was released. |
The Maestro Spectre is a CRT-aimed light gun that plugs into the same auxiliary port as the Pointer. The host has no way to distinguish them — both report through the Pointer circuitry — so the BIOS exposes a parallel API for carts whose aesthetic is shooting rather than clicking. They return the same values as the equivalent Pointer calls.
| inp.gun_x() / inp.gun_y() | Aim point in screen pixels, or
-1 if the gun is not connected. |
| inp.gun_active() | True once the gun has reported any motion or trigger this session. |
| inp.gun_trigger() | True only on the frame the trigger was pulled. Use this for the shot. |
| inp.gun_trigger_held() | True while the trigger is held; for full-auto weapons. |
| inp.gun_reload() | True on the frame the secondary (right) button was pressed. Carts use it for reloading; some use it for "aim off-screen" shenanigans. |
gun_trigger() for the right feel.
sys table| sys.time() | Seconds since the cart was loaded. Floating point. |
| sys.frame() | Frames elapsed since the cart was loaded. Useful
for animation timing without floating point. Increments
immediately before each _update. |
| sys.cart() | Returns a table with title, author, year, copied from the manifest. |
| sys.print(...) | Print to the host console. For debugging only; not visible to players. |
| sys.screen_w, sys.screen_h | Identical to gfx.W and
gfx.H. Provided for convenience. |
32 KB of CR2032-backed SRAM lives on every Maestro cartridge that ships with the appropriate trim. The BIOS exposes it as a key-indexed store. Each cart has its own keyspace (the BIOS namespaces by cart title), so saves never collide.
| sys.save_set(key, value) | Store a string or number under key. Quietly drops the write if the battery is dead (in practice, if the host's storage quota is exhausted or the player is in private-browsing mode). |
| sys.save_get(key, default) | Retrieve a previously-saved value. Returns default if no value exists. Numeric values come back as numbers; everything else as strings. |
| sys.save_clear(key) | Erase one entry. Useful for "reset progress" menus. |
The Maestro is a 60 Hz machine. Each frame has roughly 16.6 ms of CPU. Budget your draw calls accordingly:
| Triangles | Approximately 1500 per frame on real hardware. The emulator is more forgiving but cabinet builds enforce the cap. SWORDFALL uses around two dozen. |
| Sprites | 128 on-screen is the hardware ceiling. Real Maestros enforce 64 per scanline; carts that ship to retail should stagger sprite Y positions to stay under that line. |
| Affine layer | One full-screen affine pass costs roughly a fifth of the frame. Two passes are usually fine; three may glitch on slower units. |
| Tilemap definition | Defining a tilemap is expensive. Build them in
_init(), never in
_update(). |
| Palette writes | Free. Cycle and recolour ramps every frame without guilt. |
| Colour math (fade, mosaic) | Fade is essentially free (a 256-entry LUT multiply at flip). Mosaic with size > 8 starts to dominate the flip; size ≤ 4 is invisible in the budget. |
| HBlank palette events | Free per event. A few hundred per frame is unremarkable. The flip walks events linearly per row, so ten thousand events at the same scanline will start to bite. |
| HBlank Lua callback | Expensive. Each scanline crosses the cart / BIOS
boundary. A trivial callback is ~1 ms per frame; anything
substantial halves your frame rate. Use
hpal_ramp if at all possible. |
| Aria SPEAK | Each snd.say call
renders all phonemes synchronously into an audio buffer
(~5..15 ms of CPU per syllable depending on duration).
One whisper per second is fine; chained taunts in a tight loop
will hitch. |
If you blow the budget the emulator simply runs slow. On real hardware the chip drops frames silently; some demoscene carts depend on the silence as a timing signal.
A single global string — "title",
"play", "point",
"over" — switched at the top of
_update and _draw
costs nothing and reads well. SWATTER uses three states; that's
plenty for most carts.
Lua coroutines map naturally onto frame-stepped cutscenes. Yield
once per frame; resume from _update:
local script = coroutine.create(function()
for i = 1, 60 do coroutine.yield() end -- wait one second
show_dialogue("HELLO,")
for i = 1, 90 do coroutine.yield() end -- 1.5 seconds
show_dialogue("WORLD.")
end)
function _update()
if coroutine.status(script) ~= "dead" then
coroutine.resume(script)
end
end
Hex strings are tedious to author by hand. The convention in the sample carts is to declare each tile as a 16-line ASCII string, map characters to palette indices, and concatenate the result into the sheet hex at boot:
local CHARS = { ["."] = 0, ["#"] = 1, ["W"] = 15, ["R"] = 60 }
local FLY = { "..####..", ".######.", "########", ... }
local function build_sheet()
local px = {}
for y = 1, 8 do
for x = 1, 8 do
local ch = FLY[y]:sub(x, x)
px[#px + 1] = string.format("%02x", CHARS[ch] or 0)
end
end
gfx.spr_sheet(1, 8, 8, 8, 8, table.concat(px))
end
Polite carts call snd.mute_all() on
entering pause, then re-trigger any looping music on resume.
Looping samples started before pause will keep playing otherwise.
For replays or tool-assisted runs, seed the RNG explicitly in
_init with a constant. Omit
math.randomseed if you want each session
to be different. Lua 5.4 seeds itself with a "best-effort random"
at startup.
The cart below moves a sixteen-pixel block left and right with the
D-pad and prints a label. Save it as
public/carts/hello/main.lua, write a manifest,
and add an entry to index.json.
-- HELLO -- the smallest useful Maestro cart.
local x = gfx.W / 2
function _init() end
function _update()
if inp.btn("right") then x = x + 2 end
if inp.btn("left") then x = x - 2 end
if inp.btnp("a") then snd.fm(0, 440, 0.08) end
end
function _draw()
gfx.cls(0)
gfx.text("HELLO MAESTRO", 8, 8, 15)
gfx.rect(x, 100, 16, 16, 40)
end
Five carts ship with this kit. Read them in this order; each one introduces a new piece of the hardware.
For questions not covered here, write to devsupport@damocles-interactive.com. Replies are typically within five business days.
Two diagnostic facilities are baked into the rev D BIOS. Neither is intended for end users; both ship enabled. Field service personnel and approved licensees may use them at their own risk.
From the cart-select screen, hold L + R and press SELECT. The BIOS hands off to the Damocles service menu (DM-SVC-001), which offers four diagnostic pages:
Press SELECT or B to leave a diagnostic page; pick "EXIT TO CART MENU" to return to normal operation.
Damocles internal QA found that the cart-select screen accepts a secret button sequence which has to date served no documented purpose. The sequence is:
UP UP DOWN DOWN LEFT RIGHT LEFT RIGHT B A
Entered cleanly (no other button presses in between) it triggers a flash-and-mosaic celebration screen and unlocks developer mode for the remainder of the session. Whether developer mode does anything beyond looking pleased with itself is a question Damocles Interactive declines to answer publicly.
Known issues in the rev D BIOS. A future revision may fix some of these; carts that depend on them should comment the dependency.
| Issue | Status |
|---|---|
| Per-scanline affine matrices are not exposed. Carts that need horizon tilt cannot get it. | Open. Targeted for rev E. |
| The polygon rasteriser draws degenerate (zero-area) triangles as single pixels rather than nothing. | Open. Matches the S.W.O.R.D. behaviour. Some carts depend on it. |
snd.fm on a channel
held by snd.play is dropped until the
sample finishes. |
By design. Use a different channel. |
| The BIOS hides the host cursor whenever the Maestro screen is focused. Carts using the Pointer must draw their own cursor. | By design. Period accurate — the SNES Mouse drivers did the same. |
| The licensed Maestro Vision monitor displays 12:7 correctly. Most consumer 4:3 sets squash the picture by roughly 4 percent. | Won't fix. |
| Palette index 0 is treated as transparent by sprites and the affine layer. Carts that need a "hard black" pixel should define one of the user slots to be black. | Working as intended. |
|
Webmaster:
webmaster@damocles-interactive.com Last updated: November 17, 1995 You are visitor no. 00000218 |
Best viewed with Netscape Navigator 2.0 at 800 x 600 or higher.
This document supersedes DM-PR-000. |
Copyright © 1995 Damocles Interactive Ltd. All rights reserved. Maestro(TM), S.W.O.R.D.(TM), Scepter(TM), Cadenza(TM), Aria(TM) and Toccata(TM) are trademarks of Damocles Interactive Ltd.