46 KiB
JoeyAGI status
Where JoeyAGI stands against Sierra's own interpreter, subsystem by subsystem and command by command, with the evidence. Written from a clean-room deep dive (October 2026): the public AGI specifications for the formats and commands, and Sierra's interpreter (King's Quest III 2.00, interpreter 2.435, in DOSBox Staging at 386SX-16 speed; a fast 386 since 2026-10-19) run as a black box for everything the specifications leave open. No ScummVM, NAGI or Sarien source was read until 2026-10-19; since then ScummVM's AGI engine is read for behavior, never copied (it is GPL), and Sierra's interpreter still decides.
In short
- King's Quest III plays. The opening runs in step with Sierra's (title, credits over Manannan, the house, the story windows: checked with single screenshots at 9, 17 and 84 seconds); play starts in the hall with walking, typing, the parser and its unknown-word replies, the menus, the inventory screen, the F1 help screen, save, restore, restart and KQ3's debug mode and teleports all behaving as on Sierra's interpreter. Where frames still differ it is in things that move with time or chance (Manannan's sparkles, the cat, the chickens, wandering), because DOSBox runs in real time and the harness in virtual time, and each interpreter seeds its random numbers from its own clock (the generator itself is Sierra's: see "Random numbers").
- Every picture is exact. All 97 KQ3 pictures (visual and priority, 194 frames) match Sierra's pixel for pixel, as does a synthetic picture using every drawing action and all 16 pens, and a long diagonal that shows Sierra's byte-sized line error (see "Interpreter versions and quirks").
- shake.screen is Sierra's, measured frame by frame from a recording of Sierra's interpreter and reproduced beat for beat with JoeyLib's existing API (see "shake.screen").
- Sierra's window and input-line quirks are reproduced: a print.at window at column 0 or 1 is drawn and only partly erased as Sierra's byte arithmetic does it, and set.cursor.char changed mid-line moves the echo as on Sierra's. Both, and the picture-line quirk, are gated on the interpreter version read from the game's AGIDATA.OVL.
- 23 probe games, 93 frames and one recording: 89 match Sierra exactly. The others are follow.ego's random detours (its deterministic half matches) and the clock probe's four frames, which all show one measurement: a 1-second timed window that closes one cycle (50 ms) later than Sierra's (the 3-, 2- and half-second ones match).
- All six ports build; the IIgs disk boots and draws (it was blank when
the dive started), and the ST, Amiga and X68000 builds draw KQ3 correctly
(
screenshots/port-*.png). - Gates:
make checkpasses: host 124/124 (see "Gates"), disk PASS.
Not verified: playing KQ3 to the end, death scenes, the later lands (Llewdor, the ship, Daventry) beyond their pictures and a few teleports, audio by ear, and speed on the 8 MHz machines.
How it was checked
| Tool | What it does |
|---|---|
tests/hostPort.c, tests/agiHost.c |
The whole interpreter on the build machine (JoeyLib's BLANK port): virtual 60 Hz time, a scripted keyboard, every frame resolved through the palette and hashed (FNV-1a) or saved as PPM. Fully reproducible. |
tests/agiGame.py, tests/probes.py |
Builds AGI games from scratch (LOGIC assembler from the spec's command table, PICTURE byte lists, VIEW from text art, SOUND, WORDS.TOK, OBJECT). Each probe records what the interpreter did cycle by cycle into variables and prints them, so its frames do not depend on timing. |
scripts/oracle.sh |
Runs a probe (or a KQ3 session) on Sierra's interpreter in DOSBox under a private Xvfb, driven by xdotool, and on JoeyAGI, then compares every shot with tests/tools/compareFrames.py (EGA colors, text read with each interpreter's own font -- JoeyAGI's font is its own -- and Sierra's 40-column text mode compared cell by cell). A probe whose script says record is recorded instead: DOSBox's video capture (every 70 Hz frame) on Sierra's side, every frame on JoeyAGI's, compared by tests/tools/shakeMeasure.py. |
scripts/test-agi.sh |
The host gate: resources, probes and KQ3 sessions against tests/expected/. |
A caution learned on the way: DOSBox's screenshot key (Ctrl+F5) reaches the game as a key press, and KQ3's opening hurries along when it sees one. The KQ3 checkpoints below were each taken with a single screenshot.
The state found
The interpreter at the start (commit 96c72ca) was a VM/host split with many
pieces stubbed: sound set its flag at once; save.game, restore.game,
status, show.obj, menu.input, obj.status.v did nothing;
restart.game quit; object collisions and block were not modeled;
posn tests were approximations; ego stepped a fixed 2 pixels; and an argv
"starting room" let you skip "a title screen the VM can't yet advance
through". The IIgs disk gate passed, but on the IIgs the old engine was the
one drawing.
Nearly every module was rewritten (agi.c, agiRes, agiPic, agiView, agiObj, agiText, agiVm) or written new (agiGfx, agiFont, agiInput, agiSound, agiSave), each behavior settled against the specifications and then against Sierra's interpreter.
Subsystems
| Subsystem | Status | Evidence |
|---|---|---|
| Resources (directories, VOL, WORDS.TOK, OBJECT) | Works. Files read whole into buffers of their own size from the Memory Manager on the IIgs; a missing or empty directory just means none of that type. | resources-kq3: 125 logics, 97 pictures, 216 views, 34 sounds load and decode |
| AGI v3 (2026-10-20) | Works: DIR (four directories in one file), VOL.n (prefix from the known games: KQ4, GR, MH, MH2, BC, DM), 7-byte headers, LZW (9 to 11 bits) and nibble pictures expanded at load, packed logics' messages not decrypted, OBJECT decrypted only when encrypted. Gold Rush (both copies), Manhunter 1 and 2 (3.02 and 3.03), Demo 3, Black Cauldron 2.10 (3.002.098), Gold Rush 3.0, and King's Quest IV (2.0, 2.2, and 2.3 for DOS, and the Apple IIgs release) load and start; on Sierra's interpreter (as .COM) the DOS ones start the same, King's Quest IV's past a manual question JoeyAGI skips. 3.002.086's rules (loops for any loop count, the x <= 0 left border, one-argument hide.mouse and hold.key) and v3's (draw reapplies loop and cel; new.room ends a walk to a click) are in | resources for all of them; gr-start, grv201-start, mh1-start, mh2-start, mh2v303-start, bcv210-start, grv30-start, agidemo3-start, kq4-start, kq4v20-start, kq4v22-start, kq4gs-start |
| Mouse (2026-10-20) | Works on the host (the DOS interpreters had none; this follows the Amiga, ST, IIgs, and Mac ones as ScummVM describes them): a JoeyLib sprite pointer drawn at each present and kept to fence.mouse's box; a left click on the menu bar opens the menus, in the picture walks ego to it (its baseline's middle; a direction key, or in v3 a new room, stops it); the menus follow the pointer and a click picks; a click closes a message window | kq3-mouse (the pointer, a walk, the bar, a hovered item, a click choosing Help, a click closing it) |
| Game patches (agiPatch.c) | King's Quest IV's zombie distance; the manual questions of King's Quest IV (rooms 141 to 143) and Gold Rush (room 125) skipped to where a right answer leads; Manhunter 1's Field Guide question (logic 159) patched as it loads into a jump to the right answer's path; Gold Rush's clock fed the real cycle rates | kq4-start, kq4gs-start, grv201-start, mh1i102-start; the clock run at Normal, Fast, and Fastest on the host (10 game seconds in 10 seconds each) |
| PICTURE | Exact: the spec's line (exact half rounds away from the start) with Sierra's byte-sized error term, corners, relative lines, scanline fill (visual, priority-only and both), solid and splatter pens of every size with the texture wrap at bit 254 | 194/194 KQ3 frames pixel-exact; pictures probe visual + priority exact (screenshots/probe-pictures-*.png); winedge probe's long diagonal |
| VIEW | Works: loops, mirrored loops, cel transparency, descriptions; one allocation per view | resources-kq3 (every cel decoded), setview, parser (show.obj) |
| Object drawing | Exact: blits stacked with save-unders, not-updated objects under updated ones, sorted by baseline (or band top for fixed priority), control lines take the priority below them, add.to.pic margins (an outline of the control value from the baseline up to its band's top) | priority probe incl. the priority screen exact |
| Motion and cycling | Exact: step and cycle counters, the lost first step after draw/new.room/reposition, end.of.loop and reverse.loop flag timing, move.obj arrival (slack = step size), follow.ego distance. wander and follow.ego's detours are Sierra's code (read from 2.915): a heading kept about 255 cycles, a new one when an object's step leaves it standing. The cycle and motion modes share four bytes per object, as in Sierra's, so starting one overwrites what another left | timing, motion, counters, redraw, steering, cycling exact; follow deterministic rows exact |
| Ego steering | Exact: v6 steers ego before and after logic 0 under player and program control; set.dir on ego has no effect; arrow keys toggle v6 | egodir, steering, ego exact |
| Control lines, collisions, position fixing | Exact: barriers, conditional barriers and ignore.blocks, signal lines (f3), water/land, priority 15 ignoring control, the block rectangle, objects' baselines (touching counts, crossing counts), the spiral position fix | control, collide exact |
| Windows and text | Exact: print/print.at geometry and Sierra's wrap rule, display wrapping, text attributes in picture and text mode, clear.lines/clear.text.rect, the status line, timed windows (v21); print.at windows at columns 0 and 1 as Sierra's byte arithmetic draws and erases them; the screen under a window put back exactly to the pixel | windows (12 frames), winedge (18 frames), textscreen, clock exact |
| Input line | Exact: row 23 unless configure.screen moves it, no cursor until set.cursor.char, typed characters drawn in place, Enter and accept.input redraw it, prevent.input clears it; the echo keeps its own position as Sierra's does, so a cursor changed mid-line behaves as there | parser probe exact (17 frames); cursor probe exact (20 frames) |
| shake.screen | Exact: 4 beats per unit of the whole screen moved (8, 4) for 4 frames and back for 4, blocking | shake recording: beats, frames and offset identical to Sierra's |
| Interpreter version | Read from AGIDATA.OVL ("Version 2.435", "Version 3.002.149" as 3149), else the AGI file, else 2.435 (a v3 game 3149); the measured quirks follow it | resources-kq3 (KQ3: 2435) |
| Parser and said | Exact: separators, lower case, longest dictionary match incl. multi-word entries, ignored words, unknown words (stop, stay in the list for any-word/rest-of-line, v9 = position), all-ignored lines say nothing, v9 cleared each cycle | parser exact; a 10-input x 9-pattern said() matrix matched Sierra |
| Menus | Exact: bar, items, disabled items, keyboard navigation; kept across restart | parser m01..m07 exact; screenshots/kq3-menu-after-restart.png |
| Inventory (status) and show.obj | Exact layout (Sierra draws the inventory in text mode; compared cell by cell) | parser p10, m05; screenshots/kq3-inventory-tab.png |
| Sound | Flag timing exact: the flag is set when the first voice ends (Sierra's PC interpreter plays only that voice), whatever the other voices do; an empty first voice ends at once; f9 off sets it at once; stop.sound sets it. JoeyAGI plays all four voices on JoeyLib. | sound probe exact (tunes of 3 to 240 ticks, 1 to 60 notes, long tails, no first voice); audio itself not compared (PC speaker vs. JoeyLib voices) |
| Clock (v11..v14), timed windows (v21) | Clock exact; v21 windows of 3, 2 and 0.5 seconds measured identical to Sierra's against v11, the 1-second one one cycle longer | clock probe; KQ3's once-a-second countdowns traced in step with Sierra's |
| have.key | Works: a key this cycle that nothing else took, else (as Sierra's 2.915 does) one that arrived since the cycle's keys were handled, taken raw even if set.key maps it; a logic that asks again in the same cycle (KQ3's help screen spins on it) waits a frame per ask and takes any key | pictures, priority, textscreen, ego probes; screenshots/kq3-help-f1.png |
| Save / restore / restart | Work: restore rebuilds the picture from the script and ends the cycle so logic 0 sees f12 next (KQ3 relies on it); restart keeps menus and set.key mappings (KQ3 builds them only once) | kq3-saverestore session; restart + menu matches Sierra |
| Memory | Logics other than 0 are freed at new.room and so are the views no object shows, as Sierra frees a room's resources | ASan run of every session clean |
| Picture output | Every machine pushes a picture rectangle with one jlSurfacePasteIndexed call, each AGI pixel doubled (color runs through jlFillRect on the planar machines before, two jlDrawPixel calls per AGI pixel before that) | IIgs frame outside a moving object byte-identical to the old code; JoeyLib's UBER indexed checks on every port |
Bugs found and fixed in this dive, with causes
- IIgs: blank screen. Every resource came from
malloc, and on the IIgs the C heap is the small window left in the entry bank (1926 bytes with 22 KB of BSS). Logic 0 could not be allocated, so nothing ran. All interpreter memory now comes from jlAlloc (the Memory Manager); files are read into buffers of their exact size; a view is one allocation; BSS fell from 22 KB to 4 KB (byte-sized fill stack, one shared line buffer, the picture script moved into the jlAlloc'd state). - ST, Amiga, X68000: picture output. Planar stages have no chunky bytes, and the fallback drew two jlDrawPixel calls per AGI pixel. Then runs of one color per row through jlFillRect. (An 8x8 tile path was tried first and came out striped: jlTileT's bytes are in each port's own layout.) Since 2026-10-17 every machine pastes the rectangle with jlSurfacePasteIndexed (doubleWide), widened to whole 2-pixel pairs: IIgs picture pushes 11.8% -> ~4% of the CPU, X68000 ~4.7x cheaper per frame drawn.
- Text attributes. set.text.attribute in picture mode stores a background of white (or black); text.screen clears to that; on the text screen the colors are used as given; clear.lines and clear.text.rect on the text screen take the color as a PC attribute byte (so 0..15 clear to black). The old rule (background | 8) was wrong.
- Sound on at start. Sierra's interpreter sets f9 itself (its status line reads "Sound:on" in a game that never touches f9).
- Input line. Default row 23, not 22; no cursor before set.cursor.char; typing draws only the changed cell, so text displayed on the input row stays until Enter or accept.input redraws the line.
- Unknown words. said() failed whenever v9 was set; Sierra keeps the unknown word in the list, where any-word and rest-of-line match it, so "open the door now" matches said(open, rol). A line of only ignored words no longer sets f2, and v9 is cleared each cycle.
- Ego steering. v6 is copied to ego's direction before and after logic 0 in both control modes (the old code copied ego's direction into v6 under player control, so set.dir moved ego); v6 set by a logic now moves ego that same cycle.
- reposition / reposition.to skip the object's step in that cycle.
- Sound flag came after the longest voice; Sierra's comes after the first voice. This alone put KQ3's opening seconds behind (its title waits on the tune's flag).
- Restore let the rest of the cycle run (objects stepped once past the saved state) and cleared f12 before any logic saw it; KQ3's logic 0 tests f12 after a restore to fix up its menu.
- Restart wiped the menus and set.key mappings, but KQ3 builds both only on a first start (f6 clear), so after a restart Esc and the function keys did nothing.
- Resource memory grew without bound (every logic and view ever loaded stayed); now freed at new.room.
- Games without SNDDIR (or any empty directory) failed to start.
- Frame order. Each idle frame polled the keys, pumped the sound and ticked the clock before waiting for the vertical blank, so a cycle that started right after the wait saw them a frame stale: a short sound's flag arrived a cycle late. The frame now passes first.
- have.key let a frame pass every time a logic asked, and took any key that arrived then -- so a probe asking once a cycle swallowed the arrow key meant to steer ego. Now only a logic asking twice in one cycle waits.
- shake.screen was a pause. It now shakes as Sierra's does (below).
- print.at at column 0 or 1 drew a clipped window and erased it whole; Sierra's interpreter draws and erases it displaced (below).
- Window save-under went through jlSurfaceCopyRect, which widens a copy to 16-pixel groups, so closing a window also put back up to 15 stale pixels on each side: harmless while nothing beside an open window changed, wrong for the column-1 window, whose own frame and text sit there. Now exact: a chunky surface copies bytes (an AGI pixel each), a planar one copies the whole groups and the pixels at the ends one by one. Checked on all five machine ports against the host frame (0 pixels differ on ST, Amiga, X68000, DOS and IIgs).
- set.cursor.char mid-line echoed from the end of the typed text; Sierra's echo keeps its own position (below).
- Picture lines were exact to the fraction; Sierra's interpreter carries the error in a byte, which a line long on both sides overflows (below). No KQ3 picture has such a line.
Found 2026-10-19 by playing every v2 game's start on its own interpreter (scripts/oracle.sh GAME-start):
- Mixed-Up Mother Goose stayed black: logic 0 sets up only on f5 (the game starts as a room does), and it needs hold.key and set.simple (0xAA to 0xB6 were unknown commands).
- display() wrapped at 40 minus its column; Sierra's wraps at 40 counted from the text and goes on at column 0 (King's Quest 1's copyright line at column 1 is 39 characters on one row).
- %w was lower case; Sierra's keeps the word as typed (Space Quest 1: I don't understand "Wilco").
- get.string cleared its field in black; Sierra's draws the text and cursor in the current attribute (Mother Goose's name, black on its white window).
- f15 kept every window up; Sierra's clears it after the one print it covers, and show.pic clears it too (Larry's thanks after Alt+X waited on Sierra's, flashed past on JoeyAGI).
- A restore drew the input line while input was off, in the text attribute's background (a white bar under Mother Goose's picture); prevent.input now blanks it in black and nothing draws there until accept.input.
Found 2026-10-20 with the new games (the demos, the Christmas card, the fan games, and the v3 games):
- A loop of tests alone never ended: Manhunter 1 waits a second with "v11 = 0; while (v49 > v11)", and the clock moved only between cycles. A backward jump with no command since the last one now lets a frame pass, as Sierra's timer interrupt moves the clock while such a loop spins.
- The prompt was drawn raw; Sierra's expands string 0's %-codes (Gold Rush's is "%g69", ">").
- OBJECT was always decrypted; the demos' and the Christmas card's are not encrypted (encrypted, the first offset is past the file's end).
- have.key never saw a key set.key maps: Sierra's 2.915 reads the keyboard there and takes a key that came in after the cycle's keys were handled (Demo 2's Return at "Press any key", when it lands while the logics run). The first ask now polls; on the host, where keys arrive only between cycles, nothing changes.
- King's Quest IV's manual question is skipped (decision 3): a new.room to its question rooms 141 to 143 goes to room 96, where a right answer leads (agiPatch.c, the first game-specific change).
Found 2026-10-20 with the eXo releases (PLAN.md, "The games"):
- Gold Rush's clock stopped at Fast and Fastest in the cracked copy (games/gr): the crack put logic 73 in place of logic 129, the speed test that sets v165 and v167, so logic 0 copied 0 into v166. In the original the test counts a second of empty cycles at Fastest and takes a third and a quarter for a 1988 PC's playing rate; JoeyAGI holds Fastest to the frame rate, so that clock would run six times fast. Both copies now get the real rates every cycle (agiPatch.c).
- Gold Rush's quiz (the original's claim jumper, rooms 125 to 128) is skipped: logic 129's new.room(125) goes to room 73, where a right answer leads, which is also what the crack does.
- testResources failed King's Quest III 2.14, whose sound 36 shipped cut to 1035 of its 2281 bytes (the next resource follows it in VOL.1). Such a sound is now listed as truncated; the player already ends a voice where the resource ends.
Found 2026-10-21 with Sierra's DOS floppies (PLAN.md, "The games"):
- 2.089 words the status line its own way: "Score: 0 of 202" and "Sound is on" from column 27 (Space Quest 1.0X); 2.272 already has "Score:0 of 202" and "Sound:on". Gated on 2.089 and below.
- Manhunter 1 asks about its Field Guide in room 159 and quits on a wrong answer; games/mh1 is a cracked copy that never asks. The question's random(6,61,96) is patched as the logic loads into a jump to the right answer's path, the crack's own change (agiPatchLogic, which checks the bytes first). Manhunter 2's logic 159 already skips its question.
- oracle.sh picked the wrong program for a release with an installer: Gold Rush 3.0 ships GR.COM and no SIERRA.COM, so _MSTEST.EXE ran. An existing .COM is now taken first, and SIERRA.EXE (Black Cauldron 2.10's whole interpreter) before any other .EXE.
Fixed earlier in the dive (before the gates existed), all verified against Sierra's interpreter: the picture decoder (line algorithm, fill semantics, pens, splatter textures); window geometry and the wrap rule; display wrapping; the status line; menus; the inventory layout; show.obj placement; add.to.pic and its margin boxes; sprite priority masking; control-line blocking; object collisions; the position-fixing spiral; motion and cycle counter timing; move.obj arrival; follow.ego; set.view/set.loop resets of missing loops and cels; animate.obj leaving step and cycle settings alone; %-codes in messages (%v with |width, %s, %w, %0, %m, %g); the parser's longest match; the intro's timing; v21 window timeouts; a guard for KQ3's room 43 call.v(0) recursion (Sierra hangs there); Alt and Ctrl chords no longer type characters.
shake.screen
Measured on Sierra's interpreter from DOSBox's video capture, every 70 Hz
VGA frame of probe games calling shake.screen(1), (2) and (3) (the shake
probe) and (64), (65) and (0) (one-off probes); tests/tools/shakeMeasure.py
reads the shakes out of a recording:
- What moves: the whole screen -- picture, status line, displayed text and the input line -- by exactly 8 screen pixels right (4 AGI pixels) and 4 rows down, a plain shift; the uncovered strips are black. No other offset or direction occurs.
- A beat is the moved screen for 4 frames, then the screen in place for 4, locked to the vertical blank (114 ms at 70 Hz).
- Beats: 4 per unit: 1, 2, 3 gave 4, 8, 12 beats; 64 gave 256 and 65 gave 260, so the count is 16 bits, and shake.screen(0) ran past 1090 beats before the recording stopped (65536 beats, about two hours).
- It blocks: no cycles run and nothing animates until it ends.
JoeyAGI (gfxShakeScreen in agiGfx.c, JoeyLib's existing API, no per-port
code): it copies the stage to one surface and builds a second with that
screen moved (8, 4) over black (a chunky surface moves its bytes; a planar
one draws the screen as one 40x25-tile sprite at (8, 4)); each beat copies
the moved one to the stage, presents and lets 4 display frames pass
(timed with jlMillisElapsed against jlFrameHz, so a slow copy is not added
on top), then the same with the screen in place. shake.screen(0) is 65536
beats, as on Sierra's. If the two surfaces cannot be allocated the shake is
a pause of the same length. The host's recording reads the same as
Sierra's: 4, 8 and 12 beats, moved 4 frames, in place 4 frames, offset
(8, 4); the host gate pins every frame of it.
IIgs cost: a phase is a 32 KB jlSurfaceCopy into the stage (about 108 ms) and a full-screen present (about 74 ms): 11 frames (183 ms) where Sierra's takes 4, so a beat is 367 ms against Sierra's 133 ms at 60 Hz and a shake lasts 2.75 times as long. The other ports were not timed.
A JoeyLib addition that would make it much better (not made): a
display offset -- show the stage moved by (dx, dy), uncovered edges black
-- as one call (say jlStagePresentOffset(dx, dy)). Where the hardware
scrolls it is free: Amiga bitplane pointers plus BPLCON1, the X68000's
graphic scroll registers, the VGA CRTC start address; the ST (an STF's
video base moves in 256-byte steps) and the IIgs (no hardware scroll) would
present the stage shifted, one present's worth of copying instead of a
32 KB copy and a present -- about halving the IIgs's beat.
Interpreter versions and quirks
KQ3's AGIDATA.OVL names its interpreter ("Adventure Game Interpreter",
"Version 2.435"); the AGI executable carries no readable version (2.00 is
KQ3's own version, shown by the game). At startup detectVersion
(agiRes.c) reads the version from AGIDATA.OVL, else from the AGI file, else
takes 2.435, the version measured, into game.interpreterVersion;
agiQuirk() answers from one table of version ranges (kQuirkVersions),
the one place per-version behavior is decided. The IIgs disk now carries
AGIDATA.OVL, and resources-kq3 checks KQ3 reads as 2435.
Only one Sierra interpreter, 2.435, was at hand when the three quirks below were measured, so all three are on for 2.000 to 2.999, and narrowing a range is a one-line change. Off, each falls back to the plain behavior (a clipped window, an echo from the end of the typed text, an exact line). Since 2026-10-21 games/ holds every DOS interpreter from 2.089 to 3.002.149 (seventeen), so the probes can now measure each (PLAN.md, Phase 0 item 2). Rules found since are gated where they act: 2.089's status line (bug 35), 2.272's motion rules (agiObj.c), the commands of 2.425 to 3.098 (agiVm.c), and 3.002.086's (agiObj.c, agiPic.c).
- AGI_QUIRK_WINDOW_BYTE_X -- print.at at column 0 or 1. A window's box
is x = 4 * col - 5 AGI pixels, y = 8 * row - 5 screen rows, 4 * width + 10
by 8 * lines + 10, the red frame one pixel in; its sides stop a row short
of the top and bottom lines. Saving the screen under it, the white fill,
the frame and putting the screen back all address the screen as one line
of 160-pixel rows with x in a byte (row * 160 + (x & 255)), each span
running on into the next row; the text is drawn where it belongs. At
column 1 (x = -1, so 255) the save, fill and restore land a row down at
x 95 and spill into the row below from x 0, while the frame (x + 1 = 0)
and the text are in place: the window opens with its white running off to
the right, and closing it erases only its leftmost 4 * width - 55 pixels,
leaving the rest of the frame and text on screen. At column 0 (x = 251)
the box and its top, bottom and left lines are drawn a row down, 91 in,
and the right side and the text stay in place. Columns 2 and on are not
affected.
winedgeprobe: nine windows at columns 0 to 3, one to three lines, on a colored picture, open and closed: 18/18 frames identical. - AGI_QUIRK_ECHO_STEPS_BACK -- set.cursor.char mid-line. The input line
echo keeps its own position: a redraw (Enter, accept.input) leaves it
after the prompt, the text and the cursor; with a cursor set, a typed
character first steps back one -- whether or not a cursor was drawn there
-- then draws itself and the cursor; Backspace steps back two and draws
the cursor and a blank (without a cursor: back one, a blank). So a cursor
set mid-line makes the next character overwrite the last one typed
(">w", then '' set and "yz" typed, shows ">yz" while the line holds
"wyz"), a cursor taken away stays on the line (">pq_" then "r" shows
">pq_r"), and rubbing that line out with the cursor set runs back over
the prompt (three Backspaces leave just "*").
cursorprobe: 20/20 frames identical. - AGI_QUIRK_LINE_BYTE_ERROR -- picture lines (found by the
winedgepicture): the line's error term is a byte, so a line long on both sides loses steps when it passes 255: (0,0) to (159,167) stalls two rows in ten and ends at (134,167). No KQ3 picture has such a line (all 194 frames are unchanged).
Random numbers
Read from the 2.915 interpreter (Mixed-Up Mother Goose's AGI, an unencrypted MZ file; the code segment starts 512 bytes in):
- The generator. seed = seed * 0x7C4D + 1 (16 bits); the number is the seed's two bytes XORed (0 to 255). A seed of 0 is replaced first by the BIOS tick count, so it seeds itself at the first use. JoeyAGI's agiRandom does the same with its own clock (virtual time in the host harness, so runs there repeat exactly).
- random(min, max, v). The number modulo (max - min + 1) in 16 bits, plus min: a maximum below the minimum takes the number as it is. A span of 0 (max = min - 1) divides by zero in Sierra's; JoeyAGI takes the number.
- wander. Each cycle the count (shared byte 0) runs down; when it had run out, or the object's last step left it standing, a new heading, rand % 9 (0 standing still), and, while the count is under 6, a new one, rand % 51. A count that ran out has wrapped to 255, so a heading lasts about 255 cycles unless the object is stopped.
- follow.ego. Within the distance (byte 0) of ego's middle it stops and sets its flag (byte 1). The detour count (byte 2) starts at 255, meaning "head straight there". A step that left the object standing gives a heading, rand % 9 drawn until it is not 0, and a count, rand % (half the x and y distance + 1) drawn until it reaches the step size, spent a step size per cycle; Sierra's signed byte compare ends it early when the count is 128 or more.
- Standing. At each step an object's place is compared with where the last step that moved it (or position, draw) left it; the same place sets its "didn't move" flag.
The 2.4xx interpreter files (AGI) are encrypted, and their loaders decrypt them; the same generator is assumed there.
What remains, most important first
- A full play-through. KQ3 has not been finished on JoeyAGI. The rooms, puzzles, spells, deaths and endings past the house are untested beyond their resources decoding and a few teleports.
- Speed on the 8 MHz machines. Not measured. The run-length output is much faster than before; picture decoding and flood fill run in C.
- Audio by ear. Note timing and the flag are exact; how the four voices and noise sound on each port's JoeyLib audio has not been judged.
- The IIgs beyond booting. The disk gate proves it boots and draws; the opening was checked by framebuffer dumps, play on the IIgs was not.
- Save/restore look. JoeyAGI has its own slot window (slots 1-9, a description) instead of Sierra's path prompt and list, and its own file format, which is per machine type (byte order and padding).
- Random sequences are made by Sierra's generator and spent as Sierra's spends them (wander, follow.ego's detours, random), but each run seeds it from its own clock, so a run on Sierra's interpreter and one on JoeyAGI only match by chance. The generator was read from the 2.915 interpreter; the 2.4xx interpreter files are encrypted and assumed the same.
- Other interpreter versions. Only 2.435 was available to measure; the quirks are assumed for every 2.x interpreter (see below).
- The font is JoeyAGI's own, not the PC's ROM font.
- shake.screen on the IIgs takes 2.75 times as long as on Sierra's (see below); a JoeyLib display offset would fix it.
- The flood fill's seed stack (1024 seeds) would truncate a fill that needs more; no KQ3 picture does.
Commands
Every AGI v2 command is implemented. "KQ3 uses" counts the command in KQ3's logics; "Probe" marks commands the probe games exercise against Sierra's interpreter (everything KQ3 uses is also exercised by the KQ3 sessions or checkpoints). FIXED marks behavior corrected in this dive's later half; the earlier rewrite touched nearly every command.
| # | Command | KQ3 uses | Probe | Status |
|---|---|---|---|---|
| 0x00 | return | 262 | yes | works |
| 0x01 | increment | 186 | yes | works |
| 0x02 | decrement | 144 | works | |
| 0x03 | assignn | 2659 | yes | works |
| 0x04 | assignv | 111 | yes | works |
| 0x05 | addn | 106 | works | |
| 0x06 | addv | 10 | works | |
| 0x07 | subn | 29 | yes | works |
| 0x08 | subv | 7 | works | |
| 0x09 | lindirectv | 1 | works | |
| 0x0A | rindirect | 1 | works | |
| 0x0B | lindirectn | 1 | works | |
| 0x0C | set | 923 | yes | works |
| 0x0D | reset | 737 | yes | works |
| 0x0E | toggle | 12 | works | |
| 0x0F | set.v | 4 | works | |
| 0x10 | reset.v | 5 | works | |
| 0x11 | toggle.v | 0 | works | |
| 0x12 | new.room | 263 | works | |
| 0x13 | new.room.v | 14 | works | |
| 0x14 | load.logics | 135 | works | |
| 0x15 | load.logics.v | 1 | works | |
| 0x16 | call | 253 | works | |
| 0x17 | call.v | 5 | works | |
| 0x18 | load.pic | 98 | yes | no-op (draw.pic loads) |
| 0x19 | draw.pic | 102 | yes | works |
| 0x1A | show.pic | 109 | yes | works |
| 0x1B | discard.pic | 91 | yes | no-op (pictures are not kept) |
| 0x1C | overlay.pic | 9 | works | |
| 0x1D | show.pri.screen | 1 | yes | works |
| 0x1E | load.view | 375 | yes | works |
| 0x1F | load.view.v | 84 | works | |
| 0x20 | discard.view | 11 | no-op: views go at new.room | |
| 0x21 | animate.obj | 200 | yes | works |
| 0x22 | unanimate.all | 2 | works | |
| 0x23 | draw | 318 | yes | works |
| 0x24 | erase | 153 | yes | works |
| 0x25 | position | 306 | yes | works |
| 0x26 | position.v | 112 | works | |
| 0x27 | get.posn | 48 | yes | works |
| 0x28 | reposition | 12 | yes | works; FIXED (skips that cycle's step) |
| 0x29 | set.view | 477 | yes | works |
| 0x2A | set.view.v | 84 | works | |
| 0x2B | set.loop | 227 | yes | works |
| 0x2C | set.loop.v | 7 | works | |
| 0x2D | fix.loop | 44 | yes | works |
| 0x2E | release.loop | 19 | works | |
| 0x2F | set.cel | 219 | yes | works |
| 0x30 | set.cel.v | 18 | works | |
| 0x31 | last.cel | 15 | works | |
| 0x32 | current.cel | 23 | yes | works |
| 0x33 | current.loop | 11 | yes | works |
| 0x34 | current.view | 8 | yes | works |
| 0x35 | number.of.loops | 0 | works | |
| 0x36 | set.priority | 246 | yes | works |
| 0x37 | set.priority.v | 11 | works | |
| 0x38 | release.priority | 59 | works | |
| 0x39 | get.priority | 10 | works | |
| 0x3A | stop.update | 83 | yes | works |
| 0x3B | start.update | 72 | works | |
| 0x3C | force.update | 19 | works | |
| 0x3D | ignore.horizon | 59 | works | |
| 0x3E | observe.horizon | 5 | works | |
| 0x3F | set.horizon | 57 | works | |
| 0x40 | object.on.water | 6 | yes | works |
| 0x41 | object.on.land | 2 | yes | works |
| 0x42 | object.on.anything | 1 | works | |
| 0x43 | ignore.objs | 126 | yes | works |
| 0x44 | observe.objs | 15 | works | |
| 0x45 | distance | 23 | works | |
| 0x46 | stop.cycling | 150 | yes | works |
| 0x47 | start.cycling | 100 | yes | works |
| 0x48 | normal.cycle | 1 | works | |
| 0x49 | end.of.loop | 100 | yes | works |
| 0x4A | reverse.cycle | 0 | works | |
| 0x4B | reverse.loop | 23 | yes | works |
| 0x4C | cycle.time | 139 | yes | works |
| 0x4D | stop.motion | 95 | yes | works |
| 0x4E | start.motion | 72 | yes | works |
| 0x4F | step.size | 19 | yes | works |
| 0x50 | step.time | 8 | yes | works |
| 0x51 | move.obj | 155 | yes | works |
| 0x52 | move.obj.v | 59 | works | |
| 0x53 | follow.ego | 30 | yes | works |
| 0x54 | wander | 39 | works | |
| 0x55 | normal.motion | 35 | works | |
| 0x56 | set.dir | 6 | yes | works; FIXED (no effect on ego: v6 steers it) |
| 0x57 | get.dir | 4 | yes | works |
| 0x58 | ignore.blocks | 127 | yes | works |
| 0x59 | observe.blocks | 52 | works | |
| 0x5A | block | 10 | works | |
| 0x5B | unblock | 6 | works | |
| 0x5C | get | 155 | yes | works |
| 0x5D | get.v | 2 | works | |
| 0x5E | drop | 80 | works | |
| 0x5F | put | 30 | works | |
| 0x60 | put.v | 10 | works | |
| 0x61 | get.room.v | 12 | works | |
| 0x62 | load.sound | 111 | yes | no-op (sound loads) |
| 0x63 | sound | 125 | yes | works; FIXED (flag when the first voice ends) |
| 0x64 | stop.sound | 19 | yes | works; flag set |
| 0x65 | 1695 | yes | works | |
| 0x66 | print.v | 27 | works | |
| 0x67 | display | 70 | yes | works |
| 0x68 | display.v | 5 | works | |
| 0x69 | clear.lines | 17 | yes | works; FIXED (text-screen color is an attribute byte) |
| 0x6A | text.screen | 3 | yes | works; FIXED (clears to the attribute background) |
| 0x6B | graphics | 3 | yes | works |
| 0x6C | set.cursor.char | 1 | yes | works (no cursor before it: FIXED; changed mid-line moves the echo as Sierra's: FIXED) |
| 0x6D | set.text.attribute | 31 | yes | works; FIXED (picture-mode background means white) |
| 0x6E | shake.screen | 8 | yes | works; FIXED (measured: 4 beats per unit, (8, 4) for 4 frames and back for 4) |
| 0x6F | configure.screen | 1 | works | |
| 0x70 | status.line.on | 6 | yes | works |
| 0x71 | status.line.off | 6 | works | |
| 0x72 | set.string | 51 | works | |
| 0x73 | get.string | 56 | works | |
| 0x74 | word.to.string | 0 | works | |
| 0x75 | parse | 0 | works | |
| 0x76 | get.num | 13 | works | |
| 0x77 | prevent.input | 75 | works; blanks the input line in black, and nothing draws there until accept.input: FIXED (a restore drew it in the text attribute's background) | |
| 0x78 | accept.input | 45 | yes | works; redraws the input line |
| 0x79 | set.key | 39 | yes | works |
| 0x7A | add.to.pic | 66 | yes | works |
| 0x7B | add.to.pic.v | 8 | works | |
| 0x7C | status | 2 | yes | works: the inventory screen |
| 0x7D | save.game | 1 | works: JoeyAGI's own slot window and file format; under set.simple, no questions, as Sierra's (the slot with that description, else the first empty one) | |
| 0x7E | restore.game | 1 | works: JoeyAGI's own slot window; replays the picture script; FIXED (cycle ends, f12 reaches the next cycle); under set.simple, Sierra's pointer list of descriptions | |
| 0x7F | init.disk | 0 | no-op (nothing to do on these machines) | |
| 0x80 | restart.game | 1 | works; FIXED (keeps menus and set.key) | |
| 0x81 | show.obj | 61 | yes | works |
| 0x82 | random | 200 | works | |
| 0x83 | program.control | 80 | yes | works |
| 0x84 | player.control | 44 | yes | works |
| 0x85 | obj.status.v | 2 | works: a debug window | |
| 0x86 | quit | 3 | works | |
| 0x87 | show.mem | 1 | works: a JoeyAGI message | |
| 0x88 | pause | 1 | works: a message window | |
| 0x89 | echo.line | 1 | works | |
| 0x8A | cancel.line | 1 | works | |
| 0x8B | init.joy | 1 | no-op (nothing to do on these machines) | |
| 0x8C | toggle.monitor | 1 | no-op (nothing to do on these machines) | |
| 0x8D | version | 2 | works: a JoeyAGI message | |
| 0x8E | script.size | 1 | no-op (nothing to do on these machines) | |
| 0x8F | set.game.id | 1 | no-op (nothing to do on these machines) | |
| 0x90 | log | 8 | no-op (nothing to do on these machines) | |
| 0x91 | set.scan.start | 22 | works | |
| 0x92 | reset.scan.start | 18 | works | |
| 0x93 | reposition.to | 49 | yes | works; FIXED (skips that cycle's step) |
| 0x94 | reposition.to.v | 58 | works; FIXED (as reposition.to) | |
| 0x95 | trace.on | 0 | no-op (nothing to do on these machines) | |
| 0x96 | trace.info | 1 | no-op (nothing to do on these machines) | |
| 0x97 | print.at | 31 | yes | works; FIXED (columns 0 and 1 drawn and erased as Sierra's byte arithmetic does) |
| 0x98 | print.at.v | 2 | works (as print.at) | |
| 0x99 | discard.view.v | 0 | no-op: views go at new.room | |
| 0x9A | clear.text.rect | 0 | works; FIXED (as clear.lines) | |
| 0x9B | set.upper.left | 0 | no-op (nothing to do on these machines) | |
| 0x9C | set.menu | 5 | yes | works |
| 0x9D | set.menu.item | 21 | yes | works |
| 0x9E | submit.menu | 1 | yes | works |
| 0x9F | enable.item | 1 | works | |
| 0xA0 | disable.item | 3 | yes | works |
| 0xA1 | menu.input | 1 | yes | works |
| 0xA2 | show.obj.v | 1 | works | |
| 0xA3 | open.dialogue | 16 | no-op (nothing to do on these machines) | |
| 0xA4 | close.dialogue | 7 | no-op (nothing to do on these machines) | |
| 0xA5 | mul.n | 0 | works | |
| 0xA6 | mul.v | 0 | works | |
| 0xA7 | div.n | 0 | works | |
| 0xA8 | div.v | 0 | works | |
| 0xA9 | close.window | 0 | works | |
| 0xAA | set.simple | 0 | works (2.425 on, not 2.440): save and restore by a description, as Sierra's (Mother Goose names the player's save) | |
| 0xAB | push.script | 0 | no-op (as ScummVM; no game uses it) | |
| 0xAC | pop.script | 0 | no-op (as ScummVM; no game uses it) | |
| 0xAD | hold.key | 0 | works (2.425 on, not 2.440): ego walks only while a direction key is down | |
| 0xAE | set.pri.base | 0 | works (2.425, and 2.936 on): a y-to-priority table from the base down | |
| 0xAF | discard.sound | 0 | no-op (sounds are not kept) | |
| 0xB0 | hide.mouse | 0 | works: the pointer hidden; one argument on 3.002.086 | |
| 0xB1 | allow.menu | 0 | works (3.098 on): a requested menu does not open | |
| 0xB2 | show.mouse | 0 | works: the pointer shown again | |
| 0xB3 | fence.mouse | 0 | works: the pointer kept to a box in picture coordinates | |
| 0xB4 | get.mse.posn | 0 | works: the pointer in picture coordinates | |
| 0xB5 | release.key | 0 | works (3.098 on): ends hold.key | |
| 0xB6 | adj.ego.move.to.x.y | 0 | no-op (the Amiga and ST releases' two-argument form waits for Phase 5) |
| # | Test | KQ3 uses | Probe | Status |
|---|---|---|---|---|
| 0x01 | equaln | 2281 | yes | works |
| 0x02 | equalv | 35 | works | |
| 0x03 | lessn | 157 | yes | works |
| 0x04 | lessv | 12 | works | |
| 0x05 | greatern | 449 | works | |
| 0x06 | greaterv | 10 | works | |
| 0x07 | isset | 1631 | yes | works |
| 0x08 | isset.v | 1 | works | |
| 0x09 | has | 362 | works | |
| 0x0A | obj.in.room | 144 | works | |
| 0x0B | posn | 307 | works | |
| 0x0C | controller | 39 | yes | works |
| 0x0D | have.key | 4 | yes | works |
| 0x0E | said | 2991 | yes | works; FIXED (an unknown word only matches any-word and rest-of-line; all-ignored lines say nothing) |
| 0x0F | compare.strings | 28 | works | |
| 0x10 | obj.in.box | 5 | works | |
| 0x11 | center.posn | 0 | works | |
| 0x12 | right.posn | 0 | works | |
| 0x13 | in.motion.using.mouse | 0 | works: ego is walking to a click |
Gates
make check (build every port, then scripts/verify.sh):
| Gate | Result |
|---|---|
host (scripts/test-agi.sh) |
124/124: resources for the 33 v2 and 13 v3 folders in games/ (every resource loads and decodes, a sound shipped cut short listed as truncated; KQ3's interpreter version and picture hashes are checked), 29 probes (shake pins every frame of its recording), 5 KQ3 sessions (opening, play, saverestore, teleport, mouse), and a start session for each of the other 44 game folders (tests/GAME/start.scr: titles, menus, the first room, typed answers) |
disk (IIgs AGI.2mg on a CFFA2, MAME) |
PASS (boots, launches, draws: distinctNibbles=6) |
scripts/oracle.sh (not part of make check; needs Sierra's interpreters in
games/, Xvfb, xdotool), 2026-10-19: probes 97 of 103 shots identical to
Sierra's, including the shake recording, winedge (18/18) and cursor
(20/20). The rest: follow.ego's random detours, the clock probe's 1-second
window one cycle long (all four of its frames), and egoblocked's ego shown
in another cel of its walk (where it stands and what it recorded match).
The sessions (tests/GAME/*.scr, 81 shots) used to drift from Sierra's for two reasons, both fixed on 2026-10-19 in tests/tools/dosDrive.py:
- Every screenshot's real cost was added to the script's waits (about 1.5 s over twelve shots). Now each step happens at its time from the interpreter's start (DOSBox logs its switch to a graphics mode), as the host harness's virtual clock does.
- At 386SX-16 speed Sierra's interpreter spends real seconds drawing and loading, where the harness spends none (Black Cauldron's title put it 1.9 s behind). The oracle's DOSBox is now a fast 386 (50000 cycles/ms). The timing probe matches at that speed on 2.426, 2.435, and 2.439.
What still differs is chance (Manannan's orders, Larry's quiz question, the goose, follow.ego), sprites caught in another cel, Sierra's own save dialogs, and KQ3's opening, which each screenshot hurries along (the screenshot key reaches the game; see "How it was checked").
The sessions of the demos, the Christmas card, and two fan games (28 shots, 2026-10-19): 8 identical; the rest differ in color-cycled logos, the fireplace's flames, other things in motion, and AGI Mouse's pointer in The Lost Chapter (JoeyAGI's own pointer differs).
The eXo and floppy releases' start sessions (2026-10-20/21, scratch pair.sh), each on its own Sierra interpreter: King's Quest 1.0U (2.272), King's Quest 2.0F (2.425), King's Quest II 2.2 (2.426), Space Quest 1.1A (2.272) and 2.2 (2.917), Space Quest 1.0X (2.089, after fix 35), Space Quest II 2.0C, 2.0D, and 2.0F, King's Quest III 2.14, Police Quest 2.0E and 2.0G, Larry on 2.917, Black Cauldron 2.10 (3.002.098), Manhunter 2 3.03, and Gold Rush 3.0 agree with Sierra's but for chance (bullet holes, Larry's quiz question, the goose). Leads:
- Titles that run ahead on Sierra's: King's Quest III 1.01 reaches its last credits window by 17 s, ours by 30 s, and every Space Quest 1 title is a screen ahead at 12 s. Probably the screenshot key, which reaches Sierra's interpreter and hurries a title that waits for a key (as with KQ3's opening); unconfirmed.
- Gold Rush: Gold Rush 3.0 agrees through the speed test and Brooklyn Heights, but 6 s after the Return Sierra's Jerrod is further down the path than ours (gr-start's lead, now on a copy both run from the same start).
Sessions marked "JoeyAGI only", which oracle.sh leaves out, are where Sierra's interpreter asks a manual question JoeyAGI skips: Gold Rush 2.01 (the claim jumper), Manhunter 1's original data (the Field Guide), and King's Quest IV's three releases (a word from the manual, picked at random).
Screenshots
screenshots/ (git-ignored: they show Sierra's game) holds Sierra's frame and
JoeyAGI's side by side for the probes (probe-*.png) and the KQ3 checkpoints
(kq3-*.png), and the ports running KQ3 (port-*.png).