joeyagi/STATUS.md
2026-10-20 17:47:07 -05:00

674 lines
44 KiB
Markdown

# 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 check` passes: host 94/94 (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: <prefix>DIR (four directories in one file), <prefix>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, and King's Quest IV (DOS and Apple IIgs) load and start; on Sierra's interpreter (as <prefix>.COM) the DOS ones with an interpreter here start the same. 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`, `agidemo3-start`, `kq4-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; Gold Rush's clock fed the real cycle rates | `kq4-start`, `kq4gs-start`, `grv201-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
1. **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).
2. **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.
3. **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.
4. **Sound on at start.** Sierra's interpreter sets f9 itself (its status line
reads "Sound:on" in a game that never touches f9).
5. **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.
6. **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.
7. **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.
8. **reposition / reposition.to** skip the object's step in that cycle.
9. **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).
10. **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.
11. **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.
12. **Resource memory** grew without bound (every logic and view ever loaded
stayed); now freed at new.room.
13. **Games without SNDDIR** (or any empty directory) failed to start.
14. **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.
15. **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.
16. **shake.screen** was a pause. It now shakes as Sierra's does (below).
17. **print.at at column 0 or 1** drew a clipped window and erased it
whole; Sierra's interpreter draws and erases it displaced (below).
18. **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).
19. **set.cursor.char mid-line** echoed from the end of the typed text;
Sierra's echo keeps its own position (below).
20. **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):
21. **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).
22. **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).
23. **%w was lower case;** Sierra's keeps the word as typed (Space Quest 1:
I don't understand "Wilco").
24. **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).
25. **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).
26. **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):
27. **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.
28. **The prompt was drawn raw;** Sierra's expands string 0's %-codes
(Gold Rush's is "%g69", ">").
29. **OBJECT was always decrypted;** the demos' and the Christmas card's
are not encrypted (encrypted, the first offset is past the file's end).
30. **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.
31. **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"):
32. **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).
33. **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.
34. **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.
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, so whether the quirks
differ between versions could not be measured: all three are on for 2.000
to 2.999 (every version JoeyAGI plays), and narrowing a range is a one-line
change once another interpreter is measured. Off, each falls back to the
plain behavior (a clipped window, an echo from the end of the typed text,
an exact line).
- **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. `winedge` probe: 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 "*"). `cursor` probe: 20/20
frames identical.
- **AGI_QUIRK_LINE_BYTE_ERROR -- picture lines** (found by the `winedge`
picture): 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
1. **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.
2. **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.
3. **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.
4. **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.
5. **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).
6. **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.
7. **Other interpreter versions.** Only 2.435 was available to measure; the
quirks are assumed for every 2.x interpreter (see below).
8. **The font** is JoeyAGI's own, not the PC's ROM font.
9. **shake.screen on the IIgs** takes 2.75 times as long as on Sierra's
(see below); a JoeyLib display offset would fix it.
10. **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 | print | 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`) | 94/94: resources for the 22 v2 and 9 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 29 games (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 releases' start sessions (2026-10-20, scratch pair.sh): King's
Quest III 2.14 and Space Quest II 2.0F on the 2.936 from Caitlyn's Destiny,
Police Quest 2.0G on AGI Tetris's 2.917, and Black Cauldron 2.10 and
Manhunter 2 3.03 on 3.002.149 all agree with Sierra's but for chance (the
Police Quest title's bullet holes). Gold Rush 2.01's original goes to its
quiz on Sierra's, which JoeyAGI skips, so its session is marked "JoeyAGI
only" and oracle.sh leaves it out.
## 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`).