Compare commits

...

2 commits

Author SHA1 Message Date
Scott Duensing
2795f8bbf5 More game testing, enhancements, and bug fixes. 2026-10-20 17:47:19 -05:00
Scott Duensing
7926c55983 More game testing, enhancements, and bug fixes. 2026-10-20 17:47:07 -05:00
90 changed files with 3026 additions and 380 deletions

205
PLAN.md Normal file
View file

@ -0,0 +1,205 @@
# JoeyAGI plan: every Sierra AGI game
The goal is to run every Sierra AGI release. STATUS.md records where each
subsystem and command stands; this file is the road there. References:
Sierra's own interpreters, run as black boxes in DOSBox (`scripts/oracle.sh`),
and, since October 2026, the open-source implementations, ScummVM's AGI engine
first. They are read for behavior, never copied: ScummVM is GPL and JoeyAGI is
MIT. Where a source and Sierra's interpreter disagree, Sierra's interpreter
wins.
## Decisions (Scott, 2026-10-19)
| Question | Decision |
|----------|----------|
| Scope | Every DOS release of the 16 Sierra AGI titles, then the other releases (Phase 5) |
| Bugs in Sierra's game scripts that ScummVM patches | Patch them |
| Copy protection | Skip it, as ScummVM does for Gold Rush |
| Mouse | Add AGI v3's mouse: click-to-walk, the mouse commands, mouse menus |
| Save games | Keep JoeyAGI's own format; do not read Sierra's `*SG.n` files |
| Random numbers | Match Sierra's generator, so runs can be compared exactly |
## The games
Sixteen titles: King's Quest 1 to 4, Space Quest 1 and 2, Police Quest 1,
Leisure Suit Larry 1, Black Cauldron, Gold Rush, Manhunter 1 and 2, Mixed-Up
Mother Goose, Donald Duck's Playground, the AGI demos, and the 1986 Christmas
card. `games/` holds all sixteen and four fan games. ScummVM's detection
table (~/claude/scratch/joeyagi/scummvmAgi) names the known releases by
their directory files' MD5s.
On 2026-10-20 Scott's copy of the eXo ScummVM collection
(~/Downloads/eXo/eXoScummVM/eXo/eXoScummVM: read, never changed) added nine
releases to `games/` and the other machines' releases to `games/machines/`
(Phase 5, which test-agi.sh does not run yet): 13 Amiga, 11 Apple IIgs, 10
Atari ST, and 10 Macintosh. eXo's folders hold only the data, because eXo plays
them in ScummVM. Where a release lacks Sierra's interpreter, one of the same
version stands in (the "Interpreter" column), so the oracle can run it.
Still to obtain (asked of Scott 2026-10-20), each with Sierra's interpreter
files:
- Sierra's 3.002.086 (any DOS King's Quest IV AGI as shipped);
- 2.089 (Space Quest 1 1.0X, 1986-09-24) and 2.230 (the Christmas card's
version 1, 1986-10-13);
- a stock 2.936 (King's Quest III 2.14 or Space Quest II 2.0D/2.0F as
shipped), to confirm the copy in Caitlyn's Destiny;
- older releases on interpreters already covered (King's Quest 1 1.0U, King's
Quest III 1.01, Space Quest 1 1.1A, King's Quest 2 2.1, Donald Duck's
Playground 1.50, Police Quest 2.0E, the demos);
- the booter disk images (Phase 5).
| Folder | Game | Interpreter (AGIDATA.OVL) | Data |
|--------|------|---------------------------|------|
| bc | Black Cauldron | 2.439 | 386 KB |
| kq1 | King's Quest 1 | 2.917 | 344 KB |
| kq2 | King's Quest 2 | 2.917 | 488 KB |
| kq3 | King's Quest 3 | 2.435 | 651 KB |
| lsl1 | Leisure Suit Larry 1 | 2.440 | 455 KB |
| mmg | Mixed-Up Mother Goose | 2.915 | 401 KB |
| pq1 | Police Quest 1 | 2.903 | 700 KB |
| sq1 | Space Quest 1 | 2.426 | 529 KB |
| sq2 | Space Quest 2 | 2.912 | 644 KB |
| gr | Gold Rush | 3.002.149 (v3) | 1174 KB |
| mh1 | Manhunter: New York | 3.002.107 (v3) | 1034 KB |
| mh2 | Manhunter 2: San Francisco | 3.002.149 (v3) | 1189 KB |
| agidemo1 | AGI Demo 1, 1987-05-20 | 2.425 | 168 KB |
| agidemo2a | AGI Demo 2, 1987-11-24, 5.25" version 1 (its VOL.3, the second disk, is agidemo2b's: every entry matches) | 2.915 | 385 KB |
| agidemo2b | AGI Demo 2, 1987-11-24, 3.5" | 2.915 | 382 KB |
| agidemo2c | AGI Demo 2, 1987-11-25, 5.25" version 2 | 2.917 | 276 KB |
| agidemo3 | AGI Demo 3, 1988-09-13 | 3.002.102 (v3, DMDIR) | 247 KB |
| kq4gs | King's Quest IV, Apple IIgs 1.0K, 1988-11-22 (three 800K disks; IIgs font AGIFONT and instruments SIERRASTANDARD; Sierra's IIgs interpreter KQ4.SYS16) | IIgs 2.006, as DOS 3.002.086 (v3, KQ4DIR) | 1417 KB |
| xmas | The Christmas card, 1986-11-13 (version 2) | 2.272 | 178 KB |
| ddp | Donald Duck's Playground: a cracked PC booter image (DONALD.IMG) and its DOS launcher | booter (Phase 5) | 273 KB |
| kq6agi | King's Quest VI AGI demake (fan, Brandon Kouri) | 2.917 | 1469 KB |
| sqtlc | Space Quest: The Lost Chapter, mouse version (fan; AGI Mouse interpreter) | 2.917 | 1550 KB |
| sq0 | Space Quest 0: Replicated (fan; its interpreter is a Windows program) | none (2.435 assumed) | 1032 KB |
| kq3v214 | King's Quest III 2.14, 1988-03-15, 3.5" (eXo; its sound 36 shipped cut to 1035 of its 2281 bytes) | 2.936, from caitlyn | 643 KB |
| sq2v20f | Space Quest II 2.0F, 1989-01-05, 3.5" (eXo) | 2.936, from caitlyn | 637 KB |
| pq1v20g | Police Quest 2.0G, 1987-12-03 (eXo) | 2.917, AGI Tetris's (fan; kq1's quits at once on this game) | 694 KB |
| bcv210 | Black Cauldron 2.10, 1988-11-10, 5.25" (eXo; AGI 3) | 3.002.149: gr's, its built-in file prefix changed from GR to BC (a v3 interpreter carries its game's prefix, at 0x60D7) | 300 KB |
| mh2v303 | Manhunter 2 3.03, 1989-08-17, 5.25" (eXo) | 3.002.149, mh2's (3.02) | 1183 KB |
| grv201 | Gold Rush 2.01, 1988-12-22, 3.5", the original (eXo). gr, which ScummVM lists as pirated, is a cracked copy: its logic 129, the speed test, is the original's logic 73 | 3.002.149 (its own) | 1169 KB |
| kq4 | King's Quest IV 2.3, 1988-09-27, DOS (eXo) | none here (3.002.086, by its KQ4 prefix) | 1360 KB |
| ddpst | Donald Duck's Playground 1.0A, 1986-08-08: the Atari ST release's files, which eXo files as DOS | none (2.435 assumed; ScummVM takes 2.272) | 165 KB |
| caitlyn | Caitlyn's Destiny (fan, eXo): the source of the 2.936 interpreter (its AGI.COM is Sierra's SIERRA.COM; no fan hack files) | 2.936 | 1258 KB |
Starting point (2026-10-19): every v2 game but Mixed-Up Mother Goose boots
through its title into play on the host harness; Mother Goose stays black; the
v3 games cannot load yet.
Progress (2026-10-19, end of day):
- Phase 0: oracle.sh runs any game's sessions (tests/GAME/*.scr) on that
game's own loader and interpreter, and the probes on any v2 interpreter
(ORACLE_INTERP). Every v2 game has a start session in test-agi.sh
(49/49). The logic trace is AGI_HOST_VM_TRACE. Sierra's random generator
is in (read from 2.915; see STATUS.md). The timing and clock probes match
on 2.426 and 2.439; the other probes on the other interpreters are still
to run.
- Phase 1: Mother Goose plays (start, set.simple's pointer list of
players, the name, the face, the bedroom, the first room, F5 saving
under the name, restoring it). 0xAA to 0xB6 and test 0x13 are in, each
in its versions; the version keeps all three parts; the cycle and motion
modes share Sierra's four bytes; wander and follow.ego are Sierra's. Six
more differences found and fixed (STATUS.md, bugs 21 to 26).
- The oracle's shots drifted: each DOSBox screenshot's real cost was added
to the script's waits (about 1.5 s over twelve shots). Fixed: dosDrive.py
keeps each step at its time from the interpreter's start (DOSBox's log
line for the graphics mode), as the host harness's virtual clock does.
- Phase 2 (2026-10-20): done but for speed and memory (Phase 4).
- The v3 loader (agiRes.c: <prefix>DIR, LZW, nibble pictures, clear
messages in packed logics). Gold Rush, Manhunter 1 and 2, Demo 3, Black
Cauldron 2.10, and King's Quest IV (DOS and Apple IIgs) load every
resource and start. Every DOS v3 release with an interpreter here starts
as on Sierra's, run as <prefix>.COM.
- 3.002.086's rules for King's Quest IV and v3's draw and new.room rules.
- The mouse (agiMouse.c): the pointer, click-to-walk, the mouse commands,
and the menus and message windows by pointer (tests/kq3/mouse.scr, on
the host).
- agiPatch.c: King's Quest IV's zombie distance; its manual question and
Gold Rush's quiz skipped; Gold Rush's clock fed the real cycle rates.
- The eXo releases (above): King's Quest III 2.14 and Space Quest II 2.0F
match Sierra's 2.936 at the start, and Police Quest 2.0G, Black Cauldron
2.10, and Manhunter 2 3.03 match theirs. test-agi.sh 94/94.
## Phase 0: a test bench for every game
1. `scripts/oracle.sh` for any game, each run on its own interpreter (each game
folder ships one, or one of the same version stands in: fourteen versions
from 2.272 to 3.002.149).
2. The probe games run on every one of those interpreters, to measure what
differs between real versions (ScummVM treats every 2.426 to 2.440
interpreter as one); v3 interpreters need the probes in v3 format.
3. A logic trace (rooms, logics called, commands) for finding hangs.
4. Smoke sessions for every game in `scripts/test-agi.sh`, as King's Quest III
has.
5. Sierra's random number generator, matched (decision 6).
## Phase 1: every DOS v2 game (interpreters 2.089 to 2.936)
- Mixed-Up Mother Goose's black screen. Leads: the startup flags, hold.key,
set.simple, a window over the menu bar, writing v11.
- Commands 0xAA to 0xB6 and test 0x13, each in the versions that have it:
set.simple, push.script, pop.script, hold.key, set.pri.base, discard.sound,
hide.mouse, allow.menu, show.mouse, fence.mouse, get.mse.posn, release.key,
adj.ego.move.to.x.y, in.motion.using.mouse.
- Version rules: print.at with 3 arguments below 2.089 and quit with none on
2.089; move.obj's first step at once only above 2.272; loop changes that
ignore step time at 2.272 and below; save and restore stop the sound from
2.272; the 2.230 view mirror format; the note envelopes of 2.900 and 3.000.
- What some games rely on: motion and cycling sharing storage (King's Quest 1's
eagle, Black Cauldron's witches), strings surviving restart (Space Quest 2),
stopping a sound setting its flag (Police Quest 1), a tight clock-polling loop
yielding (Police Quest 1's poker).
- Sierra's script bugs, patched (decision 2): King's Quest 1's bow to the king,
King's Quest III's endless fall, the Rosella staircase and the room 77 talk,
Larry's leaving the store without paying, Space Quest 1's spider droid and
its exploding Roger (f103 still set when picture 20 is drawn).
- The interpreter version keeps all three parts (3.002.086 against 3.002.149).
## Phase 2: AGI v3 (Gold Rush, Manhunter 1 and 2, King's Quest IV, later Black Cauldron)
- The loader: `<prefix>DIR` with four section offsets, `<prefix>VOL.n`, the
7-byte resource header, LZW (9 to 11 bits, 0x100 resets, 0x101 ends),
pictures with 4-bit arguments to F0 and F2, messages not encrypted in
compressed logics.
- 3.002.086 (King's Quest IV): loops chosen for any loop count, the x <= 0 left
border, the priority-to-row glitch, one-argument hide.mouse and hold.key, the
zombie's distance (patched).
- v3 rules: draw reapplies loop and cel; new.room cancels a mouse walk;
allow.menu and release.key from 3.098.
- The mouse (decision 4): click-to-walk, the mouse commands, clicking the menu
bar and message windows, on every port.
- Game rules (done 2026-10-20): Gold Rush's clock fed the real cycle rates
(v165 and v167, which its speed test sets for a 1988 PC), and its book quiz
skipped (decision 3). ScummVM's Manhunter rule, Enter as Space when the
input line is off, is not needed: both games map Enter to Space's
controller themselves.
- Speed and memory: LZW on the 8 MHz machines, 1.2 MB of data.
## Phase 3: every game played through
- A scripted walkthrough per game on the host harness (virtual time): the main
path, deaths, the score, the endings, checked at checkpoints against the
game's own interpreter.
- Copy-protection quizzes skipped (decision 3); Larry's age quiz has its
own way past, Alt+X (a set.key that sets f109).
## Phase 4: every game on every machine
IIgs, Amiga, ST, X68000, DOS, and the hosted ports: memory, speed at 8 MHz,
disks (a CFFA2 volume per game on the IIgs; every game fits in 2 MB), and the
four-voice sound by ear.
## Phase 5: the other releases
- Amiga, Atari ST, Apple IIgs, Macintosh, and CoCo3 data: 4-byte OBJECT entries
on the Amiga, the `dirs` and `vol.N` file names, each machine's palettes and
font, and its sound: the IIgs's sampled instruments and MIDI-like music, the
CoCo3's and Apple II's own note formats.
- The v1 booter disk images: King's Quest 2, Black Cauldron, Donald Duck's
Playground (directories on disk sectors, the v1 command set, v1 words and
sound).
- The Apple II disk images.
- The Game Adaptation Language games (King's Quest 1 of 1984, Apple II King's
Quest 1), which ScummVM does not run either.

View file

@ -85,15 +85,17 @@ in `build/tests/out/`.
## Checking against Sierra's interpreter
```console
$ scripts/oracle.sh # every probe and KQ3 session
$ scripts/oracle.sh # every probe and game session
$ scripts/oracle.sh windows parser # or some of them
```
`scripts/oracle.sh` plays the probes (and `tests/kq3/*.scr`) on Sierra's own
interpreter -- the copy in `games/kq3`, in DOSBox Staging at 386SX-16 speed --
and on JoeyAGI, then compares the frames with `tests/tools/compareFrames.py`,
which reads text with each interpreter's font (JoeyAGI's is its own). The
probes record what happened into variables and print them, so their frames
match whatever the timing; the KQ3 sessions run in real time on one side and
virtual time on the other, so moving things differ by a cycle or two. STATUS.md
has the results.
`scripts/oracle.sh` plays the probes on Sierra's own interpreter (the copy in
`games/kq3`, or the game `ORACLE_INTERP` names) and each game's sessions
(`tests/GAME/*.scr`) on that game's interpreter, in DOSBox Staging as a fast
386, and on JoeyAGI, then compares the frames with
`tests/tools/compareFrames.py`, which reads text with each interpreter's font
(JoeyAGI's is its own). The probes record what happened into variables and
print them, so their frames match whatever the timing. The sessions run in
real time on one side and virtual time on the other; `tests/tools/dosDrive.py`
keeps every step at its time from the interpreter's start, so the two stay in
step and moving things differ by a cycle at most. STATUS.md has the results.

187
STATUS.md
View file

@ -4,8 +4,10 @@ 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) run as a black box for everything the
specifications leave open. No ScummVM, NAGI or Sarien source was read.
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
@ -17,7 +19,8 @@ specifications leave open. No ScummVM, NAGI or Sarien source was read.
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 the two random generators differ.
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
@ -38,8 +41,7 @@ specifications leave open. No ScummVM, NAGI or Sarien source was read.
- **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 28/28 (resources, 23 probes, 4 KQ3
sessions), disk PASS.
- **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,
@ -79,22 +81,25 @@ Sierra's interpreter.
| 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 | `timing`, `motion`, `counters`, `redraw`, `steering`, `cycling` exact; `follow` deterministic rows 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"), else the AGI file, else 2.435; the measured quirks follow it | `resources-kq3` (KQ3: 2435) |
| 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; 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` |
| 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 |
@ -172,6 +177,66 @@ 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;
@ -280,6 +345,38 @@ an exact line).
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,
@ -294,8 +391,12 @@ an exact line).
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** differ from Sierra's (wander, follow.ego detours,
KQ3's Manannan schedule): expected, not a bug.
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.
@ -433,14 +534,14 @@ the earlier rewrite touched nearly every command.
| 0x74 | word.to.string | 0 | | works |
| 0x75 | parse | 0 | | works |
| 0x76 | get.num | 13 | | works |
| 0x77 | prevent.input | 75 | | works; clears the input line |
| 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 |
| 0x7E | restore.game | 1 | | works: JoeyAGI's own slot window; replays the picture script; FIXED (cycle ends, f12 reaches the next cycle) |
| 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 |
@ -484,6 +585,19 @@ the earlier rewrite touched nearly every command.
| 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 |
|---|------|---------:|:-----:|--------|
@ -505,6 +619,7 @@ the earlier rewrite touched nearly every command.
| 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
@ -512,17 +627,45 @@ the earlier rewrite touched nearly every command.
| Gate | Result |
|------|--------|
| host (`scripts/test-agi.sh`) | 28/28: resources-kq3 (the interpreter version and 472 lines of resource hashes), 23 probes (shake pins every frame of its recording), 4 KQ3 sessions (opening, play, saverestore, teleport) |
| 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 interpreter in
games/kq3, Xvfb, xdotool): probes 89 of 94 shots identical to Sierra's
(the rest: follow.ego's random detours, and the clock probe's 1-second
window one cycle long, shown on all four of its frames), including the
shake recording and the new `winedge` (18/18) and `cursor` (20/20) probes.
Its KQ3 sessions (several shots each) drift from Sierra's with real time
and with the screenshot key reaching the game (see "How it was checked"),
which is why the KQ3 checkpoints above were taken one screenshot a run.
`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

52
agi.c
View file

@ -25,8 +25,6 @@
#include "joey/present.h"
#define CYCLE_UNIT_MS 50u
#define MS_PER_SECOND 1000u
#define SECONDS_PER_MINUTE 60u
#define MINUTES_PER_HOUR 60u
#define HOURS_PER_DAY 24u
@ -39,7 +37,11 @@
#define COMPUTER_IBM_PC 0u
#define SOUND_PC 1u
#define MONITOR_EGA 3u
#define RANDOM_SEED 0xA61DA61Du
// Sierra's random number generator (read from the 2.915 interpreter):
// seed = seed * 0x7C4D + 1 in 16 bits, the number its two bytes XORed.
#define RANDOM_MULTIPLIER 0x7C4Du
#define RANDOM_BYTE_SHIFT 8u
#define RANDOM_BYTE_MASK 0xFFu
#define STATUS_TEXT_MAX 160u
@ -65,6 +67,7 @@ static void tickClock(void);
AgiStateT *gAgi;
static uint32_t gClockMs;
static uint32_t gClockLastMs;
static uint16_t gRandomSeed;
// ----- Internal helpers (alphabetical) -----
@ -109,6 +112,11 @@ static void changeRoom(void) {
break;
}
gAgi->vars[VAR_EGO_EDGE] = AGI_EDGE_NONE;
// AGI 3 ends a walk to a click at a new room.
if (gAgi->game.v3 && ego->motion == AGI_MOTION_MOUSE) {
ego->motion = AGI_MOTION_NORMAL;
gAgi->vars[VAR_EGO_DIR] = AGI_DIR_NONE;
}
(void)vmLoadLogic(gAgi->vars[VAR_ROOM]);
gAgi->logicLoaded[gAgi->vars[VAR_ROOM]] = true;
gAgi->flags[FLAG_ENTERED] = 0u;
@ -127,12 +135,15 @@ static void cycle(void) {
gAgi->vars[VAR_BAD_WORD] = 0u;
if (gAgi->menuRequested) {
gAgi->menuRequested = false;
if (!gAgi->menuDenied) {
chosen = textMenuInput();
if (chosen != AGI_NO_CONTROLLER) {
gAgi->controllers[chosen] = 1u;
}
}
}
inputProcess();
agiPatchCycle();
// v6 steers ego, under player and program control alike, unless
// move.obj, wander or follow.ego has it: before logic 0 and again
// after it (so v6 set by a logic moves ego this cycle and set.dir on
@ -184,8 +195,8 @@ static void tickClock(void) {
now = jlMillisElapsed();
gClockMs = gClockMs + (now - gClockLastMs);
gClockLastMs = now;
while (gClockMs >= MS_PER_SECOND) {
gClockMs -= MS_PER_SECOND;
while (gClockMs >= AGI_MS_PER_SECOND) {
gClockMs -= AGI_MS_PER_SECOND;
gAgi->vars[VAR_SECONDS]++;
if (gAgi->vars[VAR_SECONDS] < SECONDS_PER_MINUTE) {
continue;
@ -216,7 +227,9 @@ void agiIdle(void) {
inputPoll();
soundPump();
tickClock();
mouseDrawPointer();
jlStagePresent();
mouseErasePointer();
jlAudioFrameTick();
}
@ -224,7 +237,7 @@ void agiIdle(void) {
// new.room: logic stops; the room changes before logic 0 runs again.
void agiNewRoom(uint8_t room) {
gAgi->newRoomPending = true;
gAgi->newRoomNumber = room;
gAgi->newRoomNumber = agiPatchRoom(room);
gAgi->abortLogic = true;
}
@ -259,6 +272,18 @@ void agiQuit(bool immediately) {
}
// A random number from 0 to 255, as Sierra's interpreter makes them. Its
// seed is the clock when the first is wanted (Sierra's takes the BIOS
// tick count), and again whenever the seed comes round to 0.
uint8_t agiRandom(void) {
if (gRandomSeed == 0u) {
gRandomSeed = (uint16_t)jlMillisElapsed();
}
gRandomSeed = (uint16_t)(gRandomSeed * RANDOM_MULTIPLIER + 1u);
return (uint8_t)((gRandomSeed ^ (gRandomSeed >> RANDOM_BYTE_SHIFT)) & RANDOM_BYTE_MASK);
}
// Everything as it is when the game starts -- but the menus and the
// set.key mappings, which a game makes once: KQ3 skips both when it is
// restarted (f6), and Sierra's interpreter keeps them.
@ -277,6 +302,11 @@ void agiResetState(void) {
gAgi->itemRoom[i] = agiItemStartRoom(&gAgi->game, (uint8_t)i);
}
gAgi->menuRequested = false;
gAgi->menuDenied = false;
gAgi->holdKey = false;
gAgi->simpleSaveOn = false;
gAgi->simpleSaveDesc[0] = '\0';
gAgi->mouseHidden = false;
gAgi->horizon = AGI_DEFAULT_HORIZON;
gAgi->blockActive = false;
gAgi->playerControl = true;
@ -302,8 +332,13 @@ void agiResetState(void) {
gAgi->vars[VAR_COMPUTER] = COMPUTER_IBM_PC;
gAgi->vars[VAR_SOUND_TYPE] = SOUND_PC;
gAgi->vars[VAR_MONITOR] = MONITOR_EGA;
// The game starts as a room does (f5): Mixed-Up Mother Goose's logic 0
// sets itself up only on f5 with v0 still 0, and without it stays black.
gAgi->flags[FLAG_NEW_ROOM] = 1u;
gAgi->flags[FLAG_FIRST_LOGIC0] = 1u;
gAgi->flags[FLAG_SOUND] = 1u;
memset(gAgi->mouseFence, 0, sizeof(gAgi->mouseFence));
agiPriorityReset();
saveScriptClear();
inputReset();
}
@ -339,7 +374,6 @@ int main(int argc, char **argv) {
jlLogReset();
jlPaletteSet(jlStageGet(), 0u, kAgiPalette);
jlSurfaceClear(jlStageGet(), AGI_COLOR_BLACK);
jlRandomSeed(RANDOM_SEED);
gAgi = (AgiStateT *)jlAlloc(sizeof(AgiStateT));
if (gAgi == NULL) {
jlShutdown();
@ -354,6 +388,7 @@ int main(int argc, char **argv) {
}
gAgi->objectCount = gAgi->game.maxObjects;
soundInit();
mouseInit();
agiResetState();
gClockLastMs = jlMillisElapsed();
lastCycleMs = gClockLastMs;
@ -373,9 +408,10 @@ int main(int argc, char **argv) {
do {
agiIdle();
now = jlMillisElapsed();
} while (now - lastCycleMs < (uint32_t)gAgi->vars[VAR_CYCLE_DELAY] * CYCLE_UNIT_MS && !gAgi->quitRequested);
} while (now - lastCycleMs < (uint32_t)gAgi->vars[VAR_CYCLE_DELAY] * AGI_CYCLE_UNIT_MS && !gAgi->quitRequested);
lastCycleMs = now;
}
mouseShutdown();
soundShutdown();
gfxFree();
agiResClose(&gAgi->game);

121
agi.h
View file

@ -13,6 +13,8 @@
// agiInput.c the keyboard, the command line and the parser
// agiSound.c PCjr sound resources on JoeyLib's tone and noise voices
// agiSave.c save, restore and the picture script restore replays
// agiMouse.c the pointer, click-to-walk, and the menus' pointer
// agiPatch.c changes to particular games: copy protection, script bugs
// agiVm.c the LOGIC interpreter
// agi.c start-up and the interpreter cycle
@ -45,6 +47,10 @@
#define AGI_TEXT_ROWS 25u
#define AGI_CELL_PX 8
// Time: v10 delays each cycle by twentieths of a second.
#define AGI_MS_PER_SECOND 1000u
#define AGI_CYCLE_UNIT_MS 50u
// Colors. The picture starts white with priority 4 (red) everywhere.
#define AGI_COLOR_BLACK 0u
#define AGI_COLOR_RED 4u
@ -121,6 +127,11 @@
#define AGI_KEY_RIGHT 0x4D00u
#define AGI_KEY_JOY_BUTTON_1 0x0101u
#define AGI_KEY_JOY_BUTTON_2 0x0201u
// The keypad's 5: ego stops (hold.key mode sends it when the direction key
// last pressed is let go).
#define AGI_KEY_STATIONARY 0x4C00u
// A left click, queued with the keys (no key has this code).
#define AGI_KEY_CLICK 0xF101u // ScummVM's code for it, so v19 reads alike
// A menu.input or inventory answer meaning "nothing chosen".
#define AGI_NO_CONTROLLER 0xFFu
@ -181,8 +192,19 @@ extern const uint8_t kAgiFont[AGI_FONT_GLYPHS][AGI_FONT_ROWS];
// The Sierra interpreter a game shipped with, as major * 1000 + minor
// (2.435 is 2435), read from the game's AGIDATA.OVL (or AGI) when its
// folder has one; otherwise the version the quirks were measured on.
// folder has one; otherwise the version the quirks were measured on, or
// for an AGI v3 game whose folder says nothing (a non-DOS release) the last
// v3 interpreter, as ScummVM assumes for most.
#define AGI_VERSION_MEASURED 2435u
#define AGI_VERSION_V3_FIRST 3000u
#define AGI_VERSION_V3_ASSUMED 3149u
// Interpreters whose rules differ: 2.272 (the Christmas card, Donald
// Duck) and 3.002.086 (every King's Quest IV).
#define AGI_VERSION_2272 2272u
#define AGI_VERSION_KQ4 3086u
// What distance reports for an object not on the screen.
#define AGI_DISTANCE_FAR 255u
// Behavior of particular Sierra interpreter versions that games may lean
// on; agiQuirk says whether the game's version has one (the version ranges
@ -218,6 +240,20 @@ typedef struct {
uint16_t offsetLow;
} AgiResEntryT;
// The longest v3 file-name prefix ("MH2" of MH2DIR and MH2VOL.0).
#define AGI_PREFIX_MAX 3u
// The games agiPatch.c changes, known by their v3 file prefix.
typedef enum {
AGI_GAME_UNKNOWN = 0,
AGI_GAME_KQ4,
AGI_GAME_GOLD_RUSH,
AGI_GAME_MANHUNTER_1,
AGI_GAME_MANHUNTER_2,
AGI_GAME_BLACK_CAULDRON,
AGI_GAME_DEMO
} AgiGameIdE;
typedef struct {
FILE *volFiles[AGI_MAX_VOLUMES];
uint16_t resCount[AGI_RES_COUNT];
@ -228,6 +264,11 @@ typedef struct {
uint16_t itemCount;
uint8_t maxObjects;
uint16_t interpreterVersion;
// AGI v3: one <prefix>DIR holds the four directories, the volumes are
// <prefix>VOL.n, and resources have 7-byte headers and may be packed.
bool v3;
char prefix[AGI_PREFIX_MAX + 1u];
AgiGameIdE id;
} AgiGameT;
@ -268,7 +309,8 @@ typedef enum {
AGI_MOTION_NORMAL = 0,
AGI_MOTION_WANDER = 1,
AGI_MOTION_FOLLOW = 2,
AGI_MOTION_MOVE = 3
AGI_MOTION_MOVE = 3,
AGI_MOTION_MOUSE = 4 // ego walking to a click (agiMouse.c)
} AgiMotionE;
// Directions: 0 is still, 1..8 run clockwise from north.
@ -306,6 +348,22 @@ typedef enum {
// after end.of.loop or reverse.loop on an object already drawn.
#define OBJ_SKIP_STEP 0x0800u
#define OBJ_SKIP_CYCLE 0x1000u
// Its last step left it where it was (wander and follow.ego turn aside).
#define OBJ_DIDNT_MOVE 0x2000u
// The four bytes an object's cycle and motion modes share, as in Sierra's
// interpreter: starting one mode overwrites what another left there, and
// games rely on it (King's Quest 1's eagle, Black Cauldron's witches).
#define AGI_OBJ_PARMS 4u
#define PARM_LOOP_FLAG 0u // end.of.loop, reverse.loop: the flag
#define PARM_WANDER_COUNT 0u // wander: cycles left on this heading
#define PARM_FOLLOW_STEP 0u // follow.ego: how near is there
#define PARM_FOLLOW_FLAG 1u
#define PARM_FOLLOW_COUNT 2u // follow.ego: a detour's steps left
#define PARM_MOVE_X 0u // move.obj
#define PARM_MOVE_Y 1u
#define PARM_MOVE_STEP 2u // move.obj: the step size to restore
#define PARM_MOVE_FLAG 3u
typedef struct {
const AgiViewT *view;
@ -328,13 +386,7 @@ typedef struct {
uint8_t cycleCount;
uint8_t cycleMode;
uint8_t motion;
uint8_t loopFlag;
uint8_t motionFlag;
uint8_t targetX;
uint8_t targetY;
uint8_t savedStepSize;
uint8_t randomSteps;
uint8_t followDistance;
uint8_t parm[AGI_OBJ_PARMS];
// Blit state: where the object was last drawn and the buffer bytes
// its cel covered there, restored when it is erased.
bool blitted;
@ -445,6 +497,21 @@ typedef struct {
bool menuSubmitted;
bool menuRequested;
uint8_t menuLastMenu;
bool menuDenied; // allow.menu(0): a requested menu does not open
// The modes of the 2.425-and-later commands: hold.key (ego walks only
// while a direction key is down), set.simple (save and restore without
// asking, under that description), set.pri.base (a y-to-priority table
// of its own instead of the 12-row bands).
bool holdKey;
bool simpleSaveOn;
char simpleSaveDesc[AGI_STRING_LEN + 1u];
bool priorityTableSet;
uint8_t priorityTable[AGI_HEIGHT];
// The mouse (AGI 3): hidden by hide.mouse, kept to fence.mouse's box.
bool mouseHidden;
uint8_t mouseFence[4];
// Requests that end the cycle, and a restore that just did.
bool quitRequested;
@ -455,6 +522,13 @@ typedef struct {
extern AgiStateT *gAgi;
// ----- agiPatch.c -----
void agiPatchCycle(void);
uint8_t agiPatchDistance(uint8_t var, uint8_t distance);
uint8_t agiPatchRoom(uint8_t room);
// ----- agiRes.c -----
void agiDecrypt(uint8_t *data, uint16_t length);
@ -464,6 +538,7 @@ bool agiQuirk(AgiQuirkE quirk);
void agiResClose(AgiGameT *game);
uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength);
bool agiResOpen(AgiGameT *game);
bool agiResPacked(const AgiGameT *game, AgiResTypeE type, uint16_t index);
bool agiResPresent(const AgiGameT *game, AgiResTypeE type, uint16_t index);
uint16_t agiWordMatch(const AgiGameT *game, const char *text, uint8_t *outLen);
@ -472,6 +547,8 @@ uint16_t agiWordMatch(const AgiGameT *game, const char *text, uint8_t *outLen
bool agiPicDecode(uint8_t *visual, uint8_t *priority, const uint8_t *data, uint16_t length);
uint8_t agiPriorityForY(int16_t y);
void agiPriorityReset(void);
void agiPrioritySetBase(uint8_t base);
int16_t agiYForPriority(uint8_t priority);
@ -509,6 +586,8 @@ void objAnimate(uint8_t n);
void objClearAll(void);
uint8_t objDirectionTo(int16_t fromX, int16_t fromY, int16_t toX, int16_t toY, uint8_t slack);
void objFixPosition(uint8_t n);
void objFollowEgo(uint8_t n, uint8_t step, uint8_t flag);
void objMouseWalk(uint8_t x, uint8_t y);
void objMoveTo(uint8_t n, uint8_t x, uint8_t y, uint8_t step, uint8_t flag);
void objPlanMotion(void);
void objSetCel(uint8_t n, uint8_t cel);
@ -518,6 +597,17 @@ void objUnanimateAll(void);
void objUpdateAll(void);
// ----- agiMouse.c -----
void mouseClick(void);
void mouseDrawPointer(void);
void mouseErasePointer(void);
void mouseInit(void);
bool mouseOver(int16_t *col, int16_t *row);
void mousePosition(uint8_t *x, uint8_t *y);
void mouseShutdown(void);
// ----- agiText.c -----
void textClearLines(uint8_t top, uint8_t bottom, uint8_t color);
@ -535,10 +625,12 @@ void textMenuAddItem(const char *name, uint8_t controller);
uint8_t textMenuInput(void);
void textMessageBox(const char *text);
void textPrint(const char *text, int16_t row, int16_t col, uint8_t width);
uint8_t textPrompt(char *out);
void textSetAttribute(uint8_t fg, uint8_t bg);
void textShowObject(uint8_t viewId);
void textTextMode(void);
void textUpdateStatus(void);
void textWindowPut(uint8_t line, uint8_t col, const char *text);
// ----- agiInput.c -----
@ -550,6 +642,7 @@ void inputParse(const char *line);
void inputPoll(void);
void inputProcess(void);
void inputReset(void);
uint16_t inputPollKey(void);
uint16_t inputWaitKey(void);
bool inputWaitPrintClose(uint16_t timeoutHalfSeconds);
@ -574,6 +667,15 @@ void saveScriptClear(void);
// ----- agiVm.c -----
// The VM's trace, in AGI_TRACE builds (the host harness): gAgiTrace, when
// set, hears every command a logic runs (its arguments; result -1) and every
// condition (op 0xFF, no arguments; result 0 or 1), with the logic and the
// command's offset in it.
#if defined(AGI_TRACE)
typedef void (*AgiTraceFnT)(uint8_t logic, uint16_t pc, uint8_t op, const uint8_t *args, uint8_t argCount, int8_t result);
extern AgiTraceFnT gAgiTrace;
#endif
void vmCallLogic(uint8_t logicId);
void vmDiscardLogics(void);
bool vmLoadLogic(uint8_t logicId);
@ -587,6 +689,7 @@ void agiIdle(void);
void agiNewRoom(uint8_t room);
void agiObjectStatus(uint8_t n);
void agiQuit(bool immediately);
uint8_t agiRandom(void);
void agiResetState(void);
void agiRestartGame(void);

View file

@ -47,7 +47,6 @@
#define SHAKE_DY 4
#define SHAKE_PHASE_FRAMES 4u
#define SHAKE_BEATS_PER_UNIT 4u
#define MS_PER_SECOND 1000u
// A chunky surface's byte holds two pixels.
#define CHUNKY_PX_PER_BYTE (JL_SURFACE_WIDTH / JL_SURFACE_BYTES_PER_ROW)
@ -601,7 +600,7 @@ void gfxShakeScreen(uint8_t times) {
jlSurfaceCopy(normal, jlStageGet());
buildShifted(shifted, normal);
}
phaseMs = (uint32_t)SHAKE_PHASE_FRAMES * MS_PER_SECOND / jlFrameHz();
phaseMs = (uint32_t)SHAKE_PHASE_FRAMES * AGI_MS_PER_SECOND / jlFrameHz();
beats = (uint16_t)((uint16_t)times * SHAKE_BEATS_PER_UNIT);
do {
shakePhase(shifted, phaseMs);

View file

@ -69,6 +69,8 @@ static uint16_t gQueue[KEY_QUEUE_SIZE];
static uint8_t gQueueHead;
static uint8_t gQueueTail;
static uint8_t gJoyDir;
// The direction key last pressed: under hold.key, letting it go stops ego.
static jlKeyE gHoldLastKey;
// A key this cycle that nothing took (no controller, not steering ego,
// no command line to type into), left for have.key.
static bool gKeyUnused;
@ -124,11 +126,17 @@ static void enqueue(uint16_t key) {
static void handleKey(uint16_t key) {
char prompt[AGI_TEXT_COLS + 1u];
uint8_t i;
uint8_t dir;
uint8_t maxLen;
uint8_t oldLen;
// A click is the mouse's (the menus or a walk), not a key for v19.
if (key == AGI_KEY_CLICK) {
mouseClick();
return;
}
gAgi->vars[VAR_KEY] = (uint8_t)key;
for (i = 0u; i < gAgi->keyMapCount; i++) {
if (gAgi->keyMaps[i].key == key) {
@ -136,10 +144,21 @@ static void handleKey(uint16_t key) {
return;
}
}
if (key == AGI_KEY_STATIONARY) {
if (gAgi->playerControl) {
gAgi->vars[VAR_EGO_DIR] = AGI_DIR_NONE;
gAgi->objects[AGI_EGO].direction = AGI_DIR_NONE;
}
return;
}
dir = keyDirection(key);
if (dir != AGI_DIR_NONE) {
// A second press of the direction ego is walking stops it.
// A second press of the direction ego is walking stops it; any
// stops a walk to a click.
if (gAgi->playerControl) {
if (gAgi->objects[AGI_EGO].motion == AGI_MOTION_MOUSE) {
gAgi->objects[AGI_EGO].motion = AGI_MOTION_NORMAL;
}
gAgi->vars[VAR_EGO_DIR] = (gAgi->vars[VAR_EGO_DIR] == dir) ? AGI_DIR_NONE : dir;
gAgi->objects[AGI_EGO].direction = gAgi->vars[VAR_EGO_DIR];
}
@ -149,7 +168,7 @@ static void handleKey(uint16_t key) {
gKeyUnused = true;
return;
}
maxLen = (uint8_t)(AGI_TEXT_COLS - 1u - strlen(gAgi->strings[0]));
maxLen = (uint8_t)(AGI_TEXT_COLS - 1u - textPrompt(prompt));
if (gAgi->vars[VAR_INPUT_MAX] != 0u && gAgi->vars[VAR_INPUT_MAX] < maxLen) {
maxLen = gAgi->vars[VAR_INPUT_MAX];
}
@ -221,6 +240,8 @@ bool inputGetString(char *dst, uint8_t maxLen, uint8_t row, uint8_t col, const c
char line[AGI_INPUT_MAX + 1u];
char shown[AGI_INPUT_MAX + 2u];
uint8_t len;
uint8_t shownLen;
uint8_t drawn;
uint16_t key;
uint8_t fieldCol;
bool onInputLine;
@ -239,12 +260,22 @@ bool inputGetString(char *dst, uint8_t maxLen, uint8_t row, uint8_t col, const c
textDisplay(row, col, prompt);
line[0] = '\0';
len = 0u;
drawn = 0u;
done = false;
while (!done) {
// The text and the cursor in the current attribute, as Sierra's
// draws them (nothing clears the field first: Mother Goose's name
// is black on its white window); spaces over what a backspace
// left behind.
memcpy(shown, line, len);
shown[len] = gAgi->cursorChar;
shown[len + 1u] = '\0';
textClearRect(row, fieldCol, row, (uint8_t)(fieldCol + maxLen), AGI_COLOR_BLACK);
shownLen = (uint8_t)strlen(shown);
while (shownLen < drawn) {
shown[shownLen++] = ' ';
}
shown[shownLen] = '\0';
drawn = shownLen;
textDisplay(row, fieldCol, shown);
key = inputWaitKey();
if (key == AGI_KEY_ESCAPE) {
@ -263,10 +294,13 @@ bool inputGetString(char *dst, uint8_t maxLen, uint8_t row, uint8_t col, const c
}
// have.key: a key this cycle that nothing else took. A logic that asks
// again in the same cycle is waiting for a key (KQ3's help screen spins
// on have.key), so from then on a frame passes per ask and any key that
// arrives counts, as Sierra's interpreter reads the keyboard there.
// have.key: a key this cycle that nothing else took, else (as Sierra's
// 2.915 does) a key that arrived since the cycle's keys were handled,
// taken as it is: one set.key maps to a controller counts too when it comes
// in while the logics run (Demo 2's Return at its "Press any key"). A
// logic that asks again in the same cycle is waiting for a key (KQ3's help
// screen spins on have.key), so from then on a frame passes per ask and any
// key that arrives counts.
bool inputHaveKey(void) {
uint16_t key;
@ -276,9 +310,10 @@ bool inputHaveKey(void) {
}
if (!gHaveKeyAsked) {
gHaveKeyAsked = true;
return false;
}
inputPoll();
} else {
agiIdle();
}
key = dequeue();
if (key == AGI_KEY_NONE) {
return false;
@ -291,29 +326,32 @@ bool inputHaveKey(void) {
// Parse a typed line into word groups for said().
void inputParse(const char *line) {
char text[AGI_INPUT_MAX + 1u];
char typed[AGI_INPUT_MAX + 1u];
uint8_t n;
uint8_t pos;
uint8_t len;
uint16_t id;
bool space;
// Lower case, punctuation to spaces, runs of spaces to one.
// Punctuation to spaces, runs of spaces to one; matched in lower case,
// while %w gives each word as it was typed (Sierra's "Wilco").
n = 0u;
space = true;
while (*line != '\0' && n < AGI_INPUT_MAX) {
char c = *line++;
if (c >= 'A' && c <= 'Z') {
c = (char)(c - 'A' + 'a');
}
if (strchr(PARSE_SEPARATORS, c) != NULL) {
if (!space) {
text[n++] = ' ';
text[n] = ' ';
typed[n] = ' ';
n++;
}
space = true;
continue;
}
text[n++] = c;
typed[n] = c;
text[n] = (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
n++;
space = false;
}
while (n > 0u && text[n - 1u] == ' ') {
@ -334,7 +372,7 @@ void inputParse(const char *line) {
// its text for %w; v9 says where it was.
len = 0u;
while (text[pos + len] != '\0' && text[pos + len] != ' ' && len < AGI_WORD_MAX_LEN) {
gAgi->wordText[gAgi->wordCount][len] = text[pos + len];
gAgi->wordText[gAgi->wordCount][len] = typed[pos + len];
len++;
}
gAgi->wordText[gAgi->wordCount][len] = '\0';
@ -344,7 +382,7 @@ void inputParse(const char *line) {
break;
}
if (id != AGI_WORD_IGNORE) {
memcpy(gAgi->wordText[gAgi->wordCount], &text[pos], len);
memcpy(gAgi->wordText[gAgi->wordCount], &typed[pos], len);
gAgi->wordText[gAgi->wordCount][len] = '\0';
gAgi->wordIds[gAgi->wordCount] = id;
gAgi->wordCount++;
@ -395,17 +433,30 @@ void inputPoll(void) {
enqueue((uint16_t)((uint16_t)(FKEY_SCAN_FIRST + i) << SCAN_SHIFT));
}
}
if (jlMousePressed(JL_MOUSE_BUTTON_LEFT)) {
enqueue(AGI_KEY_CLICK);
}
if (jlKeyPressed(JL_KEY_UP)) {
enqueue(AGI_KEY_UP);
gHoldLastKey = JL_KEY_UP;
}
if (jlKeyPressed(JL_KEY_DOWN)) {
enqueue(AGI_KEY_DOWN);
gHoldLastKey = JL_KEY_DOWN;
}
if (jlKeyPressed(JL_KEY_LEFT)) {
enqueue(AGI_KEY_LEFT);
gHoldLastKey = JL_KEY_LEFT;
}
if (jlKeyPressed(JL_KEY_RIGHT)) {
enqueue(AGI_KEY_RIGHT);
gHoldLastKey = JL_KEY_RIGHT;
}
// hold.key: ego walks only while the direction key last pressed is
// down (letting go of an earlier one changes nothing).
if (gAgi->holdKey && gHoldLastKey != JL_KEY_NONE && jlKeyReleased(gHoldLastKey)) {
enqueue(AGI_KEY_STATIONARY);
gHoldLastKey = JL_KEY_NONE;
}
if (jlJoystickConnected(JL_JOYSTICK_0)) {
if (jlJoyPressed(JL_JOYSTICK_0, JL_JOY_BUTTON_0)) {
@ -443,20 +494,32 @@ void inputReset(void) {
gQueueHead = 0u;
gQueueTail = 0u;
gKeyUnused = false;
gHoldLastKey = JL_KEY_NONE;
}
// Block until a key, keeping the game's clock and sound going.
// A key if one is waiting, else one that comes in over the next frame,
// else AGI_KEY_NONE (the menus watch the pointer between keys).
uint16_t inputPollKey(void) {
uint16_t key;
key = dequeue();
if (key == AGI_KEY_NONE) {
agiIdle();
key = dequeue();
}
return key;
}
uint16_t inputWaitKey(void) {
uint16_t key;
for (;;) {
key = dequeue();
if (key != AGI_KEY_NONE) {
do {
key = inputPollKey();
} while (key == AGI_KEY_NONE);
return key;
}
agiIdle();
}
}
@ -470,7 +533,8 @@ bool inputWaitPrintClose(uint16_t timeoutHalfSeconds) {
start = jlMillisElapsed();
for (;;) {
key = dequeue();
if (key == AGI_KEY_ENTER) {
// A click in a message window closes it as Enter does.
if (key == AGI_KEY_ENTER || key == AGI_KEY_CLICK) {
return true;
}
if (key == AGI_KEY_ESCAPE) {

232
agiMouse.c Normal file
View file

@ -0,0 +1,232 @@
// The mouse (PLAN.md decision 4). Sierra's DOS interpreters had none; its
// Amiga, ST, IIgs, and Macintosh ones did, and this follows them as
// ScummVM describes them:
//
// - A pointer is drawn over the screen at every present while the machine
// has a mouse and the game has not hidden it (hide.mouse), kept to the
// box fence.mouse gives.
// - A left click on the menu bar opens the menus (as menu.input would);
// in the picture, with the player in control, ego walks to it: its
// baseline's middle to the click (objMouseWalk). A direction key, or in
// AGI 3 a new room, stops the walk.
// - In the menus the pointer picks the menu and the item, and in a message
// window a click is Enter (agiText.c, agiInput.c).
#include "agi.h"
#include "joey/core.h"
#include "joey/input.h"
#include "joey/sprite.h"
#include "joey/tile.h"
// The pointer: a 16x16 arrow, its point the hot spot. '.' is
// transparent, 'k' its outline (dark grey: color 0, black, is the
// sprite's transparent one), 'w' its white.
#define POINTER_TILES 2u
#define POINTER_PX (POINTER_TILES * JL_TILE_PIXELS_PER_SIDE)
#define POINTER_OUTLINE 8u
#define POINTER_FILL 15u
#define PIXELS_PER_BYTE 2u
#define NIBBLE_BITS 4u
#define FENCE_X1 0u
#define FENCE_Y1 1u
#define FENCE_X2 2u
#define FENCE_Y2 3u
// ----- Prototypes -----
static void pointerAt(int16_t *x, int16_t *y);
static bool pointerShown(void);
// ----- Tables -----
static const char *const kPointerArt[POINTER_PX] = {
"k...............",
"kk..............",
"kwk.............",
"kwwk............",
"kwwwk...........",
"kwwwwk..........",
"kwwwwwk.........",
"kwwwwwwk........",
"kwwwwwwwk.......",
"kwwwwwkkkk......",
"kwwkwwk.........",
"kwk.kwwk........",
"kk..kwwk........",
"k....kwwk.......",
".....kwwk.......",
"......kk........"
};
// ----- Module state -----
static uint8_t gPointerTiles[POINTER_TILES * POINTER_TILES * JL_TILE_BYTES];
static jlSpriteT *gPointer;
static uint16_t gPointerUnder[jlSpriteBackupLength(POINTER_TILES, POINTER_TILES)];
static jlSpriteBackupT gPointerSaved;
static bool gPointerDrawn;
// ----- Internal helpers (alphabetical) -----
// Where the pointer is on the screen, kept inside fence.mouse's box (in
// picture coordinates; all zero is no fence).
static void pointerAt(int16_t *x, int16_t *y) {
const uint8_t *f;
int16_t left;
int16_t top;
int16_t right;
int16_t bottom;
*x = jlMouseX();
*y = jlMouseY();
f = gAgi->mouseFence;
if (f[FENCE_X1] == 0u && f[FENCE_Y1] == 0u && f[FENCE_X2] == 0u && f[FENCE_Y2] == 0u) {
return;
}
left = (int16_t)(f[FENCE_X1] * AGI_SCREEN_PX_PER_AGI);
right = (int16_t)(f[FENCE_X2] * AGI_SCREEN_PX_PER_AGI);
top = (int16_t)(f[FENCE_Y1] + gAgi->picRow * AGI_CELL_PX);
bottom = (int16_t)(f[FENCE_Y2] + gAgi->picRow * AGI_CELL_PX);
if (*x < left) {
*x = left;
} else if (*x > right) {
*x = right;
}
if (*y < top) {
*y = top;
} else if (*y > bottom) {
*y = bottom;
}
}
static bool pointerShown(void) {
return gPointer != NULL && jlMousePresent() && !gAgi->mouseHidden;
}
// ----- Public API (alphabetical) -----
// A left click in a cycle (inputProcess): the menus on the menu bar, else
// a walk for ego.
void mouseClick(void) {
int16_t x;
int16_t y;
uint8_t ax;
uint8_t ay;
if (!pointerShown()) {
return;
}
pointerAt(&x, &y);
if (y < (int16_t)AGI_CELL_PX) {
if (gAgi->flags[FLAG_MENU] != 0u) {
gAgi->menuRequested = true;
}
return;
}
if (gAgi->playerControl && !gAgi->textMode) {
mousePosition(&ax, &ay);
objMouseWalk(ax, ay);
}
}
// The pointer onto the stage just before a present, what it covers kept.
void mouseDrawPointer(void) {
int16_t x;
int16_t y;
if (!pointerShown()) {
return;
}
pointerAt(&x, &y);
jlSpriteSaveAndDraw(jlStageGet(), gPointer, x, y, &gPointerSaved);
gPointerDrawn = true;
}
// And off again just after, so nothing else ever draws under it.
void mouseErasePointer(void) {
if (!gPointerDrawn) {
return;
}
jlSpriteRestoreUnder(jlStageGet(), &gPointerSaved);
gPointerDrawn = false;
}
void mouseInit(void) {
uint8_t tx;
uint8_t ty;
uint8_t r;
uint8_t b;
uint8_t pixel[PIXELS_PER_BYTE];
uint8_t p;
char c;
for (ty = 0u; ty < POINTER_TILES; ty++) {
for (tx = 0u; tx < POINTER_TILES; tx++) {
uint8_t *tile = &gPointerTiles[(ty * POINTER_TILES + tx) * JL_TILE_BYTES];
for (r = 0u; r < JL_TILE_PIXELS_PER_SIDE; r++) {
for (b = 0u; b < JL_TILE_BYTES_PER_ROW; b++) {
for (p = 0u; p < PIXELS_PER_BYTE; p++) {
c = kPointerArt[ty * JL_TILE_PIXELS_PER_SIDE + r][tx * JL_TILE_PIXELS_PER_SIDE + b * PIXELS_PER_BYTE + p];
pixel[p] = (c == 'k') ? POINTER_OUTLINE : ((c == 'w') ? POINTER_FILL : 0u);
}
tile[r * JL_TILE_BYTES_PER_ROW + b] = (uint8_t)((pixel[0] << NIBBLE_BITS) | pixel[1]);
}
}
}
}
gPointerSaved.bytes = (uint8_t *)gPointerUnder;
gPointer = jlSpriteCreate(gPointerTiles, POINTER_TILES, POINTER_TILES);
if (gPointer != NULL) {
(void)jlSpriteCompile(gPointer);
}
}
// The text cell under the pointer, when one is shown.
bool mouseOver(int16_t *col, int16_t *row) {
int16_t x;
int16_t y;
if (!pointerShown()) {
return false;
}
pointerAt(&x, &y);
*col = (int16_t)(x / AGI_CELL_PX);
*row = (int16_t)(y / AGI_CELL_PX);
return true;
}
// get.mse.posn: the pointer in picture coordinates, kept to the picture.
void mousePosition(uint8_t *x, uint8_t *y) {
int16_t sx;
int16_t sy;
int16_t mx;
int16_t my;
pointerAt(&sx, &sy);
mx = (int16_t)(sx / AGI_SCREEN_PX_PER_AGI);
my = (int16_t)(sy - (int16_t)gAgi->picRow * AGI_CELL_PX);
*x = (uint8_t)(mx < 0 ? 0 : (mx > AGI_WIDTH - 1 ? AGI_WIDTH - 1 : mx));
*y = (uint8_t)(my < 0 ? 0 : (my > AGI_MAX_Y ? AGI_MAX_Y : my));
}
void mouseShutdown(void) {
if (gPointer != NULL) {
jlSpriteDestroy(gPointer);
gPointer = NULL;
}
}

212
agiObj.c
View file

@ -14,23 +14,30 @@
#include "agi.h"
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include "joey/core.h"
// Wandering objects walk a random direction for a random number of steps.
#define WANDER_STEPS_MIN 6u
#define WANDER_STEPS_SPAN 45u
#define FOLLOW_DETOUR_MIN 2u
#define FOLLOW_DETOUR_SPAN 8u
// Sierra's wander: a new heading lasts rand % 51 cycles, drawn until it is
// 6 or more. follow.ego's detour count starts at FOLLOW_START, its own
// mark for "head straight for ego".
#define WANDER_COUNT_MIN 6u
#define WANDER_COUNT_LIMIT 51u
#define FOLLOW_START 0xFFu
// A mouse walk's x for "past the left edge" (a click left of half ego's
// width): no picture x is 255.
#define MOUSE_OFF_LEFT 0xFFu
#define STEP_DEFAULT 1u
#define SPIRAL_LEGS 4u
#define SPIRAL_MAX (2u * AGI_WIDTH)
#define SIGN_STATES 3u
// Automatic loops by direction (specification 4.2): views with two or
// three loops face right or left; four or more also face up and down.
// three loops face right or left, with four also up and down; others keep
// their loop, except under King's Quest IV's 3.002.086, which turns any
// count as four.
// LOOP_KEEP leaves the loop alone.
#define LOOP_KEEP 0xFFu
#define LOOP_RIGHT 0u
@ -38,7 +45,11 @@
#define LOOP_DOWN 2u
#define LOOP_UP 3u
#define LOOPS_FOUR 4u
#define LOOPS_THREE 3u
#define LOOPS_TWO 2u
// The step count of an object stepping this cycle (objUpdateAll counts it
// down to 0 then steps).
#define STEP_DUE 1u
// ----- Prototypes -----
@ -46,12 +57,14 @@
static void advanceCel(uint8_t n);
static bool blockedByObject(uint8_t n, int16_t x, int16_t y);
static uint8_t clampToScreen(AgiObjectT *o, int16_t *x, int16_t *y);
static void followEgo(uint8_t n);
static bool legalPosition(uint8_t n, int16_t x, int16_t y);
static void moveObject(uint8_t n);
static bool onScreen(const AgiObjectT *o, int16_t x, int16_t y);
static void reportEdge(uint8_t n, uint8_t edge);
static void selectLoop(AgiObjectT *o);
static void stopSpecialMotion(uint8_t n);
static void wander(AgiObjectT *o);
// ----- Tables -----
@ -106,7 +119,7 @@ static void advanceCel(uint8_t n) {
o->cel--;
}
if ((o->cycleMode == AGI_CYCLE_END_LOOP) ? (o->cel >= last) : (o->cel == 0u)) {
gAgi->flags[o->loopFlag] = 1u;
gAgi->flags[o->parm[PARM_LOOP_FLAG]] = 1u;
o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_CYCLING);
o->cycleMode = AGI_CYCLE_NORMAL;
}
@ -150,9 +163,13 @@ static bool blockedByObject(uint8_t n, int16_t x, int16_t y) {
// it ran into, if any.
static uint8_t clampToScreen(AgiObjectT *o, int16_t *x, int16_t *y) {
uint8_t edge;
bool left;
// King's Quest IV's 3.002.086 counts x = 0 as past the left edge too
// (its room 135 hen walks off there).
edge = AGI_EDGE_NONE;
if (*x < 0) {
left = (gAgi->game.interpreterVersion == AGI_VERSION_KQ4) ? *x <= 0 : *x < 0;
if (left) {
*x = 0;
edge = AGI_EDGE_LEFT;
} else if (*x + (int16_t)o->width > AGI_WIDTH) {
@ -173,6 +190,57 @@ static uint8_t clampToScreen(AgiObjectT *o, int16_t *x, int16_t *y) {
}
// follow.ego, as Sierra's (2.915): head for ego's middle until within
// the distance follow.ego gave; there, stop and set its flag. After a step
// that left it standing, a random heading (never 0) and a detour of
// rand % (half the x and y distance to ego, + 1) steps, drawn until at
// least its step size, worked off a step size at a time; a count Sierra's
// signed byte compare finds below the step size ends the detour.
static void followEgo(uint8_t n) {
AgiObjectT *o;
const AgiObjectT *ego;
int16_t objX;
int16_t egoX;
uint16_t span;
uint8_t dir;
uint8_t *count;
o = &gAgi->objects[n];
ego = &gAgi->objects[AGI_EGO];
objX = (int16_t)(o->x + o->width / 2u);
egoX = (int16_t)(ego->x + ego->width / 2u);
dir = objDirectionTo(objX, o->y, egoX, ego->y, o->parm[PARM_FOLLOW_STEP]);
if (dir == AGI_DIR_NONE) {
stopSpecialMotion(n);
return;
}
count = &o->parm[PARM_FOLLOW_COUNT];
if (*count == FOLLOW_START) {
*count = 0u;
} else if ((o->flags & OBJ_DIDNT_MOVE) != 0u) {
do {
o->direction = (uint8_t)(agiRandom() % AGI_DIR_COUNT);
} while (o->direction == AGI_DIR_NONE);
span = (uint16_t)((uint16_t)(abs(o->y - ego->y) + abs(objX - egoX)) / 2u + 1u);
if (span <= o->stepSize) {
*count = o->stepSize;
return;
}
do {
*count = (uint8_t)(agiRandom() % span);
} while (*count < o->stepSize);
return;
}
if (*count == 0u) {
o->direction = dir;
} else if ((int8_t)*count < (int8_t)o->stepSize) {
*count = 0u;
} else {
*count = (uint8_t)(*count - o->stepSize);
}
}
// Whether the object may stand at (x, y): what its baseline covers in the
// priority screen, the block rectangle and other objects. Sets f0/f3 for
// ego as a side effect.
@ -252,14 +320,6 @@ static void moveObject(uint8_t n) {
if ((x != o->x || y != o->y) && legalPosition(n, x, y)) {
o->x = x;
o->y = y;
} else if (o->motion == AGI_MOTION_WANDER) {
o->randomSteps = 0u;
} else if (o->motion == AGI_MOTION_FOLLOW) {
// A follower that runs into something wanders off a few steps
// before it heads for ego again (measured: it never settles
// against ego's side).
o->direction = (uint8_t)(AGI_DIR_N + jlRandomRange(AGI_DIR_COUNT - 1u));
o->randomSteps = (uint8_t)(FOLLOW_DETOUR_MIN + jlRandomRange(FOLLOW_DETOUR_SPAN));
}
// Ego's edge is current: a step that touches none clears it.
if (edge != AGI_EDGE_NONE) {
@ -289,13 +349,24 @@ static void reportEdge(uint8_t n, uint8_t edge) {
static void selectLoop(AgiObjectT *o) {
uint8_t count;
uint8_t loop;
uint16_t version;
if ((o->flags & OBJ_FIX_LOOP) != 0u || o->view == NULL || o->view->loopCount < LOOPS_TWO) {
if ((o->flags & OBJ_FIX_LOOP) != 0u || o->view == NULL) {
return;
}
loop = (o->view->loopCount >= LOOPS_FOUR) ? kLoopFour[o->direction] : kLoopFew[o->direction];
if (loop != LOOP_KEEP && loop != o->loop) {
count = o->view->loopCount;
version = gAgi->game.interpreterVersion;
if (count == LOOPS_TWO || count == LOOPS_THREE) {
loop = kLoopFew[o->direction];
} else if (count == LOOPS_FOUR || version == AGI_VERSION_KQ4) {
loop = kLoopFour[o->direction];
} else {
return;
}
// After 2.272 the loop turns only as the object steps.
if (loop != LOOP_KEEP && loop != o->loop && (version <= AGI_VERSION_2272 || o->stepCount == STEP_DUE)) {
objSetLoop((uint8_t)(o - gAgi->objects), loop);
}
}
@ -307,13 +378,18 @@ static void stopSpecialMotion(uint8_t n) {
AgiObjectT *o;
o = &gAgi->objects[n];
if (o->motion == AGI_MOTION_MOVE) {
o->stepSize = o->savedStepSize;
if (o->motion == AGI_MOTION_MOVE || o->motion == AGI_MOTION_MOUSE) {
o->stepSize = o->parm[PARM_MOVE_STEP];
if (n == AGI_EGO) {
gAgi->playerControl = true;
}
}
gAgi->flags[o->motionFlag] = 1u;
// A mouse walk has no flag to set.
if (o->motion == AGI_MOTION_MOVE) {
gAgi->flags[o->parm[PARM_MOVE_FLAG]] = 1u;
} else if (o->motion == AGI_MOTION_FOLLOW) {
gAgi->flags[o->parm[PARM_FOLLOW_FLAG]] = 1u;
}
o->motion = AGI_MOTION_NORMAL;
o->direction = AGI_DIR_NONE;
if (n == AGI_EGO) {
@ -322,6 +398,26 @@ static void stopSpecialMotion(uint8_t n) {
}
// wander, as Sierra's (2.915): the count runs down each cycle; when it had
// run out, or the last step left the object standing, a new heading
// (rand % 9, 0 standing still) and, while the count is under 6, a new
// count. A count that ran out has wrapped to 255, so that heading lasts
// about 255 cycles unless something stops the object.
static void wander(AgiObjectT *o) {
uint8_t count;
count = o->parm[PARM_WANDER_COUNT];
o->parm[PARM_WANDER_COUNT] = (uint8_t)(count - 1u);
if (count != 0u && (o->flags & OBJ_DIDNT_MOVE) == 0u) {
return;
}
o->direction = (uint8_t)(agiRandom() % AGI_DIR_COUNT);
while (o->parm[PARM_WANDER_COUNT] < WANDER_COUNT_MIN) {
o->parm[PARM_WANDER_COUNT] = (uint8_t)(agiRandom() % WANDER_COUNT_LIMIT);
}
}
// ----- Public API (alphabetical) -----
// animate.obj: bring an object under the interpreter's control. Its
@ -414,18 +510,48 @@ void objFixPosition(uint8_t n) {
// move.obj: head for (x, y) at `step` pixels a step (0 keeps the step
// size); flag is set on arrival.
// follow.ego: within step (at least the object's step size) of ego, set
// flag. The detour count starts at FOLLOW_START.
void objFollowEgo(uint8_t n, uint8_t step, uint8_t flag) {
AgiObjectT *o;
o = &gAgi->objects[n];
o->motion = AGI_MOTION_FOLLOW;
o->parm[PARM_FOLLOW_STEP] = (step > o->stepSize) ? step : o->stepSize;
o->parm[PARM_FOLLOW_FLAG] = flag;
o->parm[PARM_FOLLOW_COUNT] = FOLLOW_START;
gAgi->flags[flag] = 0u;
}
// A click's walk (agiMouse.c): ego heads, as for move.obj but with no flag
// and the player keeping control, for where its baseline's middle is at
// (x, y).
void objMouseWalk(uint8_t x, uint8_t y) {
AgiObjectT *ego;
uint8_t half;
ego = &gAgi->objects[AGI_EGO];
half = (uint8_t)(ego->width / 2u);
ego->motion = AGI_MOTION_MOUSE;
ego->parm[PARM_MOVE_X] = (x < half) ? MOUSE_OFF_LEFT : (uint8_t)(x - half);
ego->parm[PARM_MOVE_Y] = y;
ego->parm[PARM_MOVE_STEP] = ego->stepSize;
}
void objMoveTo(uint8_t n, uint8_t x, uint8_t y, uint8_t step, uint8_t flag) {
AgiObjectT *o;
o = &gAgi->objects[n];
o->motion = AGI_MOTION_MOVE;
o->targetX = x;
o->targetY = y;
o->savedStepSize = o->stepSize;
o->parm[PARM_MOVE_X] = x;
o->parm[PARM_MOVE_Y] = y;
o->parm[PARM_MOVE_STEP] = o->stepSize;
o->parm[PARM_MOVE_FLAG] = flag;
if (step != 0u) {
o->stepSize = step;
}
o->motionFlag = flag;
o->direction = objDirectionTo(o->x, o->y, x, y, o->stepSize);
gAgi->flags[flag] = 0u;
if (n == AGI_EGO) {
@ -443,9 +569,7 @@ void objMoveTo(uint8_t n, uint8_t x, uint8_t y, uint8_t step, uint8_t flag) {
void objPlanMotion(void) {
uint8_t i;
AgiObjectT *o;
AgiObjectT *ego;
ego = &gAgi->objects[AGI_EGO];
for (i = 0u; i < gAgi->objectCount; i++) {
o = &gAgi->objects[i];
if ((o->flags & (OBJ_ANIMATED | OBJ_UPDATE | OBJ_DRAWN)) != (OBJ_ANIMATED | OBJ_UPDATE | OBJ_DRAWN)) {
@ -453,26 +577,14 @@ void objPlanMotion(void) {
}
switch (o->motion) {
case AGI_MOTION_WANDER:
if (o->randomSteps == 0u) {
o->direction = (uint8_t)jlRandomRange(AGI_DIR_COUNT);
o->randomSteps = (uint8_t)(WANDER_STEPS_MIN + jlRandomRange(WANDER_STEPS_SPAN));
} else {
o->randomSteps--;
}
wander(o);
break;
case AGI_MOTION_FOLLOW:
if (o->randomSteps > 0u) {
o->randomSteps--;
break;
}
o->direction = objDirectionTo((int16_t)(o->x + o->width / 2u), o->y,
(int16_t)(ego->x + ego->width / 2u), ego->y, o->followDistance);
if (o->direction == AGI_DIR_NONE) {
stopSpecialMotion(i);
}
followEgo(i);
break;
case AGI_MOTION_MOVE:
o->direction = objDirectionTo(o->x, o->y, o->targetX, o->targetY, o->stepSize);
case AGI_MOTION_MOUSE:
o->direction = objDirectionTo(o->x, o->y, (o->motion == AGI_MOTION_MOUSE && o->parm[PARM_MOVE_X] == MOUSE_OFF_LEFT) ? -1 : (int16_t)o->parm[PARM_MOVE_X], o->parm[PARM_MOVE_Y], o->stepSize);
if (o->direction == AGI_DIR_NONE) {
stopSpecialMotion(i);
}
@ -596,13 +708,21 @@ void objUpdateAll(void) {
}
if (o->stepCount == 0u) {
o->stepCount = o->stepTime;
o->prevX = o->x;
o->prevY = o->y;
if ((o->flags & OBJ_SKIP_STEP) != 0u) {
o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_SKIP_STEP);
} else {
moveObject(i);
}
// Where the step left it against where the last step that
// moved it (or a position command) did, as Sierra's does at
// each step: wander and follow.ego turn aside when it stood.
if (o->x == o->prevX && o->y == o->prevY) {
o->flags = (uint16_t)(o->flags | OBJ_DIDNT_MOVE);
} else {
o->prevX = o->x;
o->prevY = o->y;
o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_DIDNT_MOVE);
}
}
}
gfxRedrawUpdating();

105
agiPatch.c Normal file
View file

@ -0,0 +1,105 @@
// Changes to particular games, as PLAN.md's decisions have them: copy
// protection is skipped (decision 3) and the script bugs Sierra shipped are
// patched (decision 2). A game is known by its v3 file prefix (agiRes.c).
#include "agi.h"
#include "joey/core.h"
// Gold Rush's clock: logic 0 counts v166 cycles to a second. At a change
// of speed it copies v166 from v167 (Fastest) or v165 (Fast), which the
// speed test in logic 129 sets: it counts a second of empty cycles at
// Fastest and takes a third and a quarter of them (at most 20) for the
// rate a playing cycle would run at on a 1988 PC. Here Fastest is held to
// the frame rate, playing or not, so the clock would run six times too
// fast; and the cracked copy (games/gr) lost the test with logic 129, so
// its clock stops. Both are filled with the real rates every cycle: Fast
// is one cycle a unit, Fastest one a frame (agiIdle waits for each).
#define GR_FAST_CPS_VAR 165u
#define GR_FASTEST_CPS_VAR 167u
#define GR_FAST_CPS (AGI_MS_PER_SECOND / AGI_CYCLE_UNIT_MS)
// King's Quest IV's graveyards (rooms 16 and 18): three zombies rise at
// night, each one's distance from Rosella measured into v221..v223 and its
// state kept in v155, v156, and v162 (room 16) or v158 (room 18).
#define KQ4_GRAVEYARD_A 16u
#define KQ4_GRAVEYARD_B 18u
#define KQ4_ZOMBIE_DIST 221u
#define KQ4_ZOMBIES 3u
#define KQ4_ZOMBIE_CHASING 2u
// The copy protection (decision 3): the rooms in which a game asks a
// question from its manual, and the room a right answer goes on to.
typedef struct {
AgiGameIdE game;
uint8_t first;
uint8_t last;
uint8_t after;
} QuestionRoomsT;
// ----- Tables -----
static const uint8_t kKq4ZombieStateA[KQ4_ZOMBIES] = { 155u, 156u, 162u };
static const uint8_t kKq4ZombieStateB[KQ4_ZOMBIES] = { 155u, 156u, 158u };
static const QuestionRoomsT kQuestionRooms[] = {
// King's Quest IV asks a word from its manual in one of rooms 141 to
// 143; the title, room 96, follows.
{ AGI_GAME_KQ4, 141u, 143u, 96u },
// Gold Rush's claim jumper asks about the book "California Gold" on
// the way from the title (logic 129 to room 125, which picks one of
// rooms 126 to 128); room 73 follows. The cracked copy's logic 129 is
// the original's logic 73 and never comes here.
{ AGI_GAME_GOLD_RUSH, 125u, 125u, 73u }
};
// ----- Public API (alphabetical) -----
// Before each cycle's logic 0.
void agiPatchCycle(void) {
if (gAgi->game.id != AGI_GAME_GOLD_RUSH) {
return;
}
gAgi->vars[GR_FAST_CPS_VAR] = (uint8_t)GR_FAST_CPS;
gAgi->vars[GR_FASTEST_CPS_VAR] = (uint8_t)jlFrameHz();
}
// The distance the distance command reports into var. King's Quest IV's
// zombie bug (in Sierra's interpreter too): with the obsidian scarab, a
// zombie rising beside Rosella is turned away before it is up, so it
// rises and never chases. Patched as ScummVM does: until a zombie chases,
// it reads as far away.
uint8_t agiPatchDistance(uint8_t var, uint8_t distance) {
uint8_t room;
uint8_t zombie;
room = gAgi->vars[VAR_ROOM];
if (gAgi->game.id != AGI_GAME_KQ4 || (room != KQ4_GRAVEYARD_A && room != KQ4_GRAVEYARD_B) || var < KQ4_ZOMBIE_DIST || var >= KQ4_ZOMBIE_DIST + KQ4_ZOMBIES) {
return distance;
}
zombie = (uint8_t)(var - KQ4_ZOMBIE_DIST);
if (gAgi->vars[(room == KQ4_GRAVEYARD_A) ? kKq4ZombieStateA[zombie] : kKq4ZombieStateB[zombie]] != KQ4_ZOMBIE_CHASING) {
return AGI_DISTANCE_FAR;
}
return distance;
}
// The room new.room really goes to: a manual question is skipped.
uint8_t agiPatchRoom(uint8_t room) {
const QuestionRoomsT *q;
uint8_t i;
for (i = 0u; i < sizeof(kQuestionRooms) / sizeof(kQuestionRooms[0]); i++) {
q = &kQuestionRooms[i];
if (gAgi->game.id == q->game && room >= q->first && room <= q->last) {
return q->after;
}
}
return room;
}

View file

@ -32,6 +32,14 @@
#define REL_Y_SIGN 0x08u
#define REL_MAG_MASK 0x07u
// set.pri.base: priority 4 above the base row, then from 5 in bands of
// (168 - base) * 168 / 10 / 168 rows, at most 15 (ScummVM's formula,
// graphics.cpp setPriorityTable). Up to 3.002.086 the lowest row of a
// priority is always 168 once a table is set (ScummVM, priorityToY: a
// glitch of the original that King's Quest IV's room 54 needs).
#define PRI_TABLE_BANDS 10
#define PRI_GLITCH_LAST AGI_VERSION_KQ4
// Pen byte: bit 5 splatter, bit 4 rectangle, bits 0-2 the size.
#define PEN_SPLATTER 0x20u
#define PEN_RECTANGLE 0x10u
@ -517,19 +525,55 @@ bool agiPicDecode(uint8_t *visual, uint8_t *priority, const uint8_t *data, uint1
uint8_t agiPriorityForY(int16_t y) {
if (y < AGI_PRI_BAND_TOP) {
return AGI_PRIORITY_MIN;
}
if (y > AGI_MAX_Y) {
y = AGI_MAX_Y;
}
if (gAgi->priorityTableSet) {
return gAgi->priorityTable[y < 0 ? 0 : y];
}
if (y < AGI_PRI_BAND_TOP) {
return AGI_PRIORITY_MIN;
}
return (uint8_t)(AGI_PRI_FIRST_BAND + (uint8_t)((y - AGI_PRI_BAND_TOP) / AGI_PRI_BAND_HEIGHT));
}
// The 12-row bands again: a new game, a restart, a restore.
void agiPriorityReset(void) {
gAgi->priorityTableSet = false;
}
// set.pri.base: the table from base down (PRI_TABLE_BANDS).
void agiPrioritySetBase(uint8_t base) {
int16_t band;
int16_t y;
int16_t p;
band = (int16_t)((AGI_HEIGHT - (int16_t)base) * AGI_HEIGHT / PRI_TABLE_BANDS);
for (y = 0; y < AGI_HEIGHT; y++) {
p = (y < (int16_t)base || band <= 0) ? (int16_t)AGI_PRIORITY_MIN : (int16_t)((y - (int16_t)base) * AGI_HEIGHT / band + AGI_PRI_FIRST_BAND);
gAgi->priorityTable[y] = (uint8_t)(p > (int16_t)AGI_PRIORITY_MAX ? (int16_t)AGI_PRIORITY_MAX : p);
}
gAgi->priorityTableSet = true;
}
// The lowest y that has a priority: where an object with that fixed
// priority sorts among the others when they are drawn.
// priority sorts among the others when they are drawn. With a table, the
// row below it, scanned up from the bottom as ScummVM does, or 168 up to
// 3.002.086 (PRI_GLITCH_LAST).
int16_t agiYForPriority(uint8_t priority) {
int16_t y;
if (gAgi->priorityTableSet) {
if (gAgi->game.interpreterVersion <= PRI_GLITCH_LAST) {
return AGI_HEIGHT;
}
for (y = AGI_MAX_Y; y >= 0 && gAgi->priorityTable[y] >= priority; y--) {
}
return y;
}
if (priority <= AGI_PRIORITY_MIN) {
return 0;
}

387
agiRes.c
View file

@ -1,14 +1,23 @@
// AGI v2 resources: the four directories, the VOL files, WORDS.TOK and
// AGI resources: the four directories, the VOL files, WORDS.TOK and
// OBJECT (AGI specification chapters 5 and 10).
//
// A directory is a flat array of 3-byte entries: the high nibble of the
// first byte is the VOL file, the remaining 20 bits the offset into it;
// FF FF FF marks an unused number. A resource in a VOL file starts with a
// 5-byte header: 12 34, the volume, then the little-endian length.
//
// AGI v3 keeps the four directories in one file, <prefix>DIR (GRDIR,
// MH2DIR), after four little-endian offsets to them, and the volumes in
// <prefix>VOL.n. Its headers are 7 bytes: 12 34, the volume, the length,
// then the length stored. A stored length short of the length means LZW
// (expanded here); a picture whose volume byte has bit 7 set instead keeps
// the colors of F0 and F2 in a nibble each (spread back into bytes here),
// so the rest of the game sees v2 resources.
#include "agi.h"
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include "joey/core.h"
@ -21,13 +30,42 @@
#define DIR_VOLUME_SHIFT 4u
#define DIR_OFFSET_HIGH_MASK 0x0Fu
#define DIR_OFFSET_HIGH_SHIFT 16u
#define VOL_HEADER_BYTES 5u
#define VOL_HEADER_V2_BYTES 5u
#define VOL_HEADER_V3_BYTES 7u
#define VOL_HEADER_MAX VOL_HEADER_V3_BYTES
#define VOL_SIG_0 0x12u
#define VOL_SIG_1 0x34u
#define VOL_COMPRESSED 0x80u
#define VOL_VOLUME_BYTE 2u
#define VOL_LENGTH_BYTE 3u
#define VOL_STORED_BYTE 5u
#define VOL_NIBBLE_PICTURE 0x80u
#define VOL_VOLUME_MASK 0x7Fu
#define VOL_NAME_MAX 8u
#define VOL_NAME_MAX (AGI_PREFIX_MAX + sizeof("VOL.15"))
#define DIR_NAME_MAX (AGI_PREFIX_MAX + sizeof("DIR"))
#define DECIMAL_BASE 10u
#define DIR_V3_OFFSETS_BYTES 8u
#define DIR_V3_MAX_BYTES (DIR_V3_OFFSETS_BYTES + AGI_RES_COUNT * AGI_MAX_RESOURCES * DIR_ENTRY_BYTES)
#define BYTE_BITS 8u
#define NIBBLE_BITS 4u
#define LOW_NIBBLE 0x0Fu
// AGI v3's LZW, as the data has it: codes come least significant bit
// first, 9 bits wide and growing a bit (to 11) as the next free code would
// need it; 0x100 starts the table over, 0x101 ends, and every stream
// starts with 0x100.
#define LZW_START_BITS 9u
#define LZW_MAX_BITS 11u
#define LZW_RESET 0x100u
#define LZW_END 0x101u
#define LZW_FIRST_FREE 0x102u
#define LZW_TABLE_SIZE (1u << LZW_MAX_BITS)
#define LZW_LITERAL_MAX 0xFFu
#define LZW_NONE 0xFFFFu
// The picture commands whose colors a v3 nibble picture packs.
#define PIC_SET_COLOR 0xF0u
#define PIC_SET_PRIORITY 0xF2u
#define PIC_END 0xFFu
// OBJECT: a 3-byte header (offset of the names, the number of animated
// objects) then a 3-byte entry per item (name offset, starting room).
@ -59,11 +97,17 @@
// ----- Prototypes -----
static void buildVolName(char *out, uint8_t volNum);
static void buildVolName(const AgiGameT *game, char *out, uint8_t volNum);
static uint16_t detectVersion(void);
static uint8_t *expandNibblePicture(const uint8_t *in, uint16_t inLen, uint16_t *outLen);
static void fillDirectory(AgiGameT *game, AgiResTypeE type, const uint8_t *bytes, uint16_t length);
static void loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type);
static void loadDirectoryV3(AgiGameT *game);
static uint8_t *loadFile(const char *name, uint16_t maxBytes, uint16_t *outLength);
static bool loadObjects(AgiGameT *game);
static bool lzwExpand(const uint8_t *in, uint16_t inLen, uint8_t *out, uint16_t outLen);
static uint8_t nibbleAt(const uint8_t *data, uint32_t k);
static uint16_t readLe16(const uint8_t *p);
static bool resHeaderRead(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint8_t *header);
@ -75,6 +119,27 @@ static const char *const kDirNames[AGI_RES_COUNT] = { "LOGDIR", "PICDIR", "VIEWD
// Where the version string may be, in the order to look.
static const char *const kVersionFiles[] = { "AGIDATA.OVL", "AGI" };
// The v3 games' file-name prefixes, which also tell which game it is, and
// the interpreter to behave as when the folder names none (another
// machine's release: every King's Quest IV is 3.002.086's, the rest are
// taken as the last, 3.002.149). ScummVM finds the prefix by listing the
// folder for a file ending in DIR; JoeyLib lists no folders, so the known
// ones are tried.
typedef struct {
const char *prefix;
AgiGameIdE id;
uint16_t version;
} V3GameT;
static const V3GameT kV3Games[] = {
{ "KQ4", AGI_GAME_KQ4, AGI_VERSION_KQ4 },
{ "GR", AGI_GAME_GOLD_RUSH, AGI_VERSION_V3_ASSUMED },
{ "MH", AGI_GAME_MANHUNTER_1, AGI_VERSION_V3_ASSUMED },
{ "MH2", AGI_GAME_MANHUNTER_2, AGI_VERSION_V3_ASSUMED },
{ "BC", AGI_GAME_BLACK_CAULDRON, AGI_VERSION_V3_ASSUMED },
{ "DM", AGI_GAME_DEMO, AGI_VERSION_V3_ASSUMED }
};
// The interpreter versions each quirk is known in. Measured on 2.435
// (King's Quest III 2.00) only, the one Sierra interpreter at hand; they
// come from the PC interpreter's window, input-line and picture code, so
@ -94,19 +159,8 @@ static const QuirkRangeT kQuirkVersions[AGI_QUIRK_COUNT] = {
// ----- Internal helpers (alphabetical) -----
static void buildVolName(char *out, uint8_t volNum) {
uint8_t n;
n = 0u;
out[n++] = 'V';
out[n++] = 'O';
out[n++] = 'L';
out[n++] = '.';
if (volNum >= DECIMAL_BASE) {
out[n++] = (char)('0' + volNum / DECIMAL_BASE);
}
out[n++] = (char)('0' + volNum % DECIMAL_BASE);
out[n] = '\0';
static void buildVolName(const AgiGameT *game, char *out, uint8_t volNum) {
(void)snprintf(out, VOL_NAME_MAX, "%sVOL.%u", game->prefix, (unsigned)volNum);
}
@ -139,6 +193,14 @@ static uint16_t detectVersion(void) {
for (j = (uint16_t)(j + 2u); j < length && data[j] >= '0' && data[j] <= '9'; j++) {
minor = (uint16_t)(minor * DECIMAL_BASE + (uint16_t)(data[j] - '0'));
}
// AGI 3 names three parts, 3.002.149: the last one tells them
// apart (3149), as ScummVM numbers them.
if (j + 1u < length && data[j] == '.' && data[j + 1u] >= '0' && data[j + 1u] <= '9') {
minor = 0u;
for (j = (uint16_t)(j + 1u); j < length && data[j] >= '0' && data[j] <= '9'; j++) {
minor = (uint16_t)(minor * DECIMAL_BASE + (uint16_t)(data[j] - '0'));
}
}
jlFree(data);
return (uint16_t)(major * VERSION_MAJOR_SCALE + minor);
}
@ -148,19 +210,52 @@ static uint16_t detectVersion(void) {
}
// A missing or empty directory just means none of that type (a game
// without sounds); the game needs logic 0 (agiResOpen).
static void loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type) {
uint8_t *bytes;
uint16_t length;
// A v3 nibble picture spread back into bytes: each byte is two nibbles
// from wherever the last one ended, and the color after F0 or F2 one.
static uint8_t *expandNibblePicture(const uint8_t *in, uint16_t inLen, uint16_t *outLen) {
uint8_t *out;
uint32_t nibbles;
uint32_t k;
uint16_t n;
uint8_t b;
// Every output byte uses at least one nibble.
nibbles = (uint32_t)inLen * 2u;
if (nibbles > UINT16_MAX) {
nibbles = UINT16_MAX;
}
out = (uint8_t *)jlAlloc(nibbles);
if (out == NULL) {
return NULL;
}
n = 0u;
k = 0u;
while (k + 1u < nibbles) {
b = (uint8_t)((nibbleAt(in, k) << NIBBLE_BITS) | nibbleAt(in, k + 1u));
k += 2u;
out[n++] = b;
if (b == PIC_END) {
break;
}
if ((b == PIC_SET_COLOR || b == PIC_SET_PRIORITY) && k < nibbles) {
out[n++] = nibbleAt(in, k);
k++;
}
}
*outLen = n;
return out;
}
// The 3-byte entries of one directory.
static void fillDirectory(AgiGameT *game, AgiResTypeE type, const uint8_t *bytes, uint16_t length) {
uint16_t count;
uint16_t i;
bytes = loadFile(fileName, (uint16_t)(AGI_MAX_RESOURCES * DIR_ENTRY_BYTES), &length);
if (bytes == NULL) {
return;
}
count = (uint16_t)(length / DIR_ENTRY_BYTES);
if (count > AGI_MAX_RESOURCES) {
count = AGI_MAX_RESOURCES;
}
for (i = 0u; i < count; i++) {
const uint8_t *e = &bytes[i * DIR_ENTRY_BYTES];
AgiResEntryT *d = &game->resDir[type][i];
@ -174,10 +269,61 @@ static void loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type
}
}
game->resCount[type] = count;
}
// A missing or empty directory just means none of that type (a game
// without sounds); the game needs logic 0 (agiResOpen).
static void loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type) {
uint8_t *bytes;
uint16_t length;
bytes = loadFile(fileName, (uint16_t)(AGI_MAX_RESOURCES * DIR_ENTRY_BYTES), &length);
if (bytes == NULL) {
return;
}
fillDirectory(game, type, bytes, length);
jlFree(bytes);
}
// An AGI v3 game's <prefix>DIR: four offsets, then the logic, picture,
// view and sound directories, each running to the next (the last to the
// end of the file).
static void loadDirectoryV3(AgiGameT *game) {
char name[DIR_NAME_MAX];
uint8_t *bytes;
uint16_t length;
uint16_t start;
uint16_t end;
uint8_t p;
uint8_t t;
for (p = 0u; p < sizeof(kV3Games) / sizeof(kV3Games[0]); p++) {
(void)snprintf(name, sizeof(name), "%sDIR", kV3Games[p].prefix);
bytes = loadFile(name, (uint16_t)DIR_V3_MAX_BYTES, &length);
if (bytes == NULL) {
continue;
}
if (length >= DIR_V3_OFFSETS_BYTES) {
for (t = 0u; t < AGI_RES_COUNT; t++) {
start = readLe16(&bytes[t * 2u]);
end = (t + 1u < AGI_RES_COUNT) ? readLe16(&bytes[(t + 1u) * 2u]) : length;
if (start <= end && end <= length) {
fillDirectory(game, (AgiResTypeE)t, &bytes[start], (uint16_t)(end - start));
}
}
strcpy(game->prefix, kV3Games[p].prefix);
game->id = kV3Games[p].id;
game->interpreterVersion = kV3Games[p].version;
game->v3 = true;
}
jlFree(bytes);
return;
}
}
// Read a whole data file, at most maxBytes of it, into a buffer of its
// own size (one fread: on the IIgs each call is a GS/OS round trip).
// Returns NULL when it is missing or empty.
@ -223,8 +369,13 @@ static bool loadObjects(AgiGameT *game) {
jlFree(data);
return false;
}
// Most OBJECT files are XORed with the LOGIC messages' key, a few (the
// demos', the Christmas card's) not: encrypted, the first offset falls
// past the end of the file.
if (readLe16(data) > length) {
agiDecrypt(data, length);
namesOffset = (uint16_t)(data[0] | ((uint16_t)data[1] << 8));
}
namesOffset = readLe16(data);
game->objectData = data;
game->itemCount = (uint16_t)(namesOffset / OBJECT_ENTRY_BYTES);
if (game->itemCount > AGI_MAX_ITEMS) {
@ -238,6 +389,120 @@ static bool loadObjects(AgiGameT *game) {
}
// LZW-packed v3 data into exactly outLen bytes (any shortfall zeroed).
// False when the work tables cannot be had or the data makes no sense.
static bool lzwExpand(const uint8_t *in, uint16_t inLen, uint8_t *out, uint16_t outLen) {
uint16_t *prefix;
uint8_t *suffix;
uint8_t *stack;
uint32_t bits;
uint8_t bitCount;
uint8_t width;
uint16_t inPos;
uint16_t outPos;
uint16_t next;
uint16_t prev;
uint16_t code;
uint16_t cur;
uint16_t depth;
uint8_t first;
bool ok;
prefix = (uint16_t *)jlAlloc(LZW_TABLE_SIZE * sizeof(uint16_t));
suffix = (uint8_t *)jlAlloc(LZW_TABLE_SIZE);
stack = (uint8_t *)jlAlloc(LZW_TABLE_SIZE);
ok = prefix != NULL && suffix != NULL && stack != NULL;
memset(out, 0, outLen);
bits = 0u;
bitCount = 0u;
width = LZW_START_BITS;
inPos = 0u;
outPos = 0u;
next = LZW_FIRST_FREE;
prev = LZW_NONE;
first = 0u;
while (ok && outPos < outLen) {
while (bitCount < width && inPos < inLen) {
bits |= (uint32_t)in[inPos++] << bitCount;
bitCount = (uint8_t)(bitCount + BYTE_BITS);
}
if (bitCount < width) {
break;
}
code = (uint16_t)(bits & ((1u << width) - 1u));
bits >>= width;
bitCount = (uint8_t)(bitCount - width);
if (code == LZW_END) {
break;
}
if (code == LZW_RESET) {
width = LZW_START_BITS;
next = LZW_FIRST_FREE;
prev = LZW_NONE;
continue;
}
// The first code after a reset is a byte of its own.
if (prev == LZW_NONE) {
ok = code <= LZW_LITERAL_MAX;
if (ok) {
out[outPos++] = (uint8_t)code;
prev = code;
first = (uint8_t)code;
}
continue;
}
// The code's string, last byte first; the one code not yet in the
// table (next) is the previous string and its own first byte.
depth = 0u;
cur = code;
if (code >= next) {
ok = code == next;
stack[depth++] = first;
cur = prev;
}
while (ok && cur > LZW_LITERAL_MAX) {
ok = depth < LZW_TABLE_SIZE - 1u;
if (ok) {
stack[depth++] = suffix[cur];
cur = prefix[cur];
}
}
if (!ok) {
break;
}
stack[depth++] = (uint8_t)cur;
first = (uint8_t)cur;
while (depth > 0u && outPos < outLen) {
out[outPos++] = stack[--depth];
}
if (next < LZW_TABLE_SIZE) {
if (next >= (1u << width) - 1u && width < LZW_MAX_BITS) {
width++;
}
prefix[next] = prev;
suffix[next] = first;
next++;
}
prev = code;
}
jlFree(prefix);
jlFree(suffix);
jlFree(stack);
return ok;
}
// Nibble k of data, the high one of each byte first.
static uint8_t nibbleAt(const uint8_t *data, uint32_t k) {
return ((k & 1u) != 0u) ? (uint8_t)(data[k >> 1] & LOW_NIBBLE) : (uint8_t)(data[k >> 1] >> NIBBLE_BITS);
}
static uint16_t readLe16(const uint8_t *p) {
return (uint16_t)(p[0] | ((uint16_t)p[1] << 8));
}
// Seek to resource index's header in its VOL file and read it: false when
// the number is past its directory or unused, or its entry names a VOL file
// the game lacks or a place in one that holds no header for that volume
@ -250,6 +515,7 @@ static bool resHeaderRead(const AgiGameT *game, AgiResTypeE type, uint16_t index
const AgiResEntryT *entry;
FILE *fp;
long offset;
size_t size;
if (index >= game->resCount[type]) {
return false;
@ -263,10 +529,11 @@ static bool resHeaderRead(const AgiGameT *game, AgiResTypeE type, uint16_t index
return false;
}
offset = ((long)entry->offsetHigh << DIR_OFFSET_HIGH_SHIFT) | (long)entry->offsetLow;
if (fseek(fp, offset, SEEK_SET) != 0 || fread(header, 1, VOL_HEADER_BYTES, fp) != VOL_HEADER_BYTES) {
size = game->v3 ? VOL_HEADER_V3_BYTES : VOL_HEADER_V2_BYTES;
if (fseek(fp, offset, SEEK_SET) != 0 || fread(header, 1, size, fp) != size) {
return false;
}
return header[0] == VOL_SIG_0 && header[1] == VOL_SIG_1 && (header[2] & VOL_VOLUME_MASK) == entry->volume;
return header[0] == VOL_SIG_0 && header[1] == VOL_SIG_1 && (header[VOL_VOLUME_BYTE] & VOL_VOLUME_MASK) == entry->volume;
}
@ -329,33 +596,45 @@ void agiResClose(AgiGameT *game) {
}
// A resource, as v2 stores it: v3's packed ones expanded (see the top).
uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength) {
FILE *fp;
uint8_t header[VOL_HEADER_BYTES];
uint8_t header[VOL_HEADER_MAX];
uint8_t *stored;
uint8_t *payload;
uint16_t length;
uint16_t storedLength;
if (!resHeaderRead(game, type, index, header)) {
return NULL;
}
// v3 resources are LZW-compressed and flag it in the volume byte.
if ((header[2] & VOL_COMPRESSED) != 0u) {
return NULL;
}
fp = game->volFiles[game->resDir[type][index].volume];
length = (uint16_t)(header[3] | ((uint16_t)header[4] << 8));
if (length == 0u) {
length = readLe16(&header[VOL_LENGTH_BYTE]);
storedLength = game->v3 ? readLe16(&header[VOL_STORED_BYTE]) : length;
if (length == 0u || storedLength == 0u) {
return NULL;
}
stored = (uint8_t *)jlAlloc(storedLength);
if (stored == NULL) {
return NULL;
}
if (fread(stored, 1, storedLength, fp) != storedLength) {
jlFree(stored);
return NULL;
}
payload = stored;
if (game->v3 && (header[VOL_VOLUME_BYTE] & VOL_NIBBLE_PICTURE) != 0u) {
payload = expandNibblePicture(stored, storedLength, &length);
jlFree(stored);
} else if (storedLength != length) {
payload = (uint8_t *)jlAlloc(length);
if (payload == NULL) {
return NULL;
}
if (fread(payload, 1, length, fp) != length) {
if (payload != NULL && !lzwExpand(stored, storedLength, payload, length)) {
jlFree(payload);
return NULL;
payload = NULL;
}
if (outLength != NULL) {
jlFree(stored);
}
if (payload != NULL && outLength != NULL) {
*outLength = length;
}
return payload;
@ -366,19 +645,23 @@ bool agiResOpen(AgiGameT *game) {
char name[VOL_NAME_MAX];
uint8_t t;
uint8_t v;
uint16_t version;
bool anyVolume;
memset(game, 0, sizeof(*game));
for (t = 0u; t < AGI_RES_COUNT; t++) {
loadDirectory(game, kDirNames[t], (AgiResTypeE)t);
}
if (game->resCount[AGI_RES_LOGIC] == 0u) {
loadDirectoryV3(game);
}
if (game->resCount[AGI_RES_LOGIC] == 0u) {
agiResClose(game);
return false;
}
anyVolume = false;
for (v = 0u; v < AGI_MAX_VOLUMES; v++) {
buildVolName(name, v);
buildVolName(game, name, v);
game->volFiles[v] = jlDataOpen(name, "rb");
if (game->volFiles[v] != NULL) {
anyVolume = true;
@ -398,15 +681,27 @@ bool agiResOpen(AgiGameT *game) {
if (!loadObjects(game)) {
game->maxObjects = AGI_MAX_OBJECTS;
}
game->interpreterVersion = detectVersion();
version = detectVersion();
if (!game->v3 || version >= AGI_VERSION_V3_FIRST) {
game->interpreterVersion = version;
}
return true;
}
// Whether resource index of type is LZW-packed (v3), so its LOGIC messages
// are not encrypted.
bool agiResPacked(const AgiGameT *game, AgiResTypeE type, uint16_t index) {
uint8_t header[VOL_HEADER_MAX];
return game->v3 && resHeaderRead(game, type, index, header) && (header[VOL_VOLUME_BYTE] & VOL_NIBBLE_PICTURE) == 0u && readLe16(&header[VOL_LENGTH_BYTE]) != readLe16(&header[VOL_STORED_BYTE]);
}
// Whether resource index of type is in the game's files: its entry points at
// a resource header in its VOL file (resHeaderRead).
bool agiResPresent(const AgiGameT *game, AgiResTypeE type, uint16_t index) {
uint8_t header[VOL_HEADER_BYTES];
uint8_t header[VOL_HEADER_MAX];
return resHeaderRead(game, type, index, header);
}

232
agiSave.c
View file

@ -11,6 +11,13 @@
// The files are JoeyLib save files (SAVES/), AGISAVE1..AGISAVE9, in
// JoeyAGI's own format: the state as the machine holds it, so a save made
// on one kind of machine is for that kind only.
//
// set.simple names the saves itself, as Sierra's interpreter does: save
// goes without asking to the slot with that description, else to the
// first empty one (with every slot taken by others, a pointer picks one to
// overwrite), and restore lists the saves by description for a pointer.
// The pointer starts on the newest save (each is numbered one above the
// highest on the disk) and wraps at either end of the list.
#include "agi.h"
@ -26,10 +33,17 @@
#define SAVE_MAGIC_0 'J'
#define SAVE_MAGIC_1 'A'
#define SAVE_MAGIC_2 'G'
#define SAVE_VERSION 1u
#define SAVE_VERSION 2u
#define SAVE_SLOTS 9u
#define SAVE_DESC_MAX 30u
#define SAVE_NAME_MAX 12u
// The pointer lists: their longest header, the box's width (as Sierra's,
// whatever the text), and the pointer drawn over the first column of an
// entry (" - name").
#define POINTER_HEADER_LINES 4u
#define POINTER_BOX_COLS 34
#define POINTER_ON ">"
#define POINTER_OFF " "
// The slot window is as wide as the screen allows with its frame.
#define PICK_WIDTH (AGI_TEXT_COLS - 4u)
@ -51,18 +65,13 @@ typedef struct {
uint8_t cycleCount;
uint8_t cycleMode;
uint8_t motion;
uint8_t loopFlag;
uint8_t motionFlag;
uint8_t targetX;
uint8_t targetY;
uint8_t savedStepSize;
uint8_t randomSteps;
uint8_t followDistance;
uint8_t parm[AGI_OBJ_PARMS];
} SavedObjectT;
typedef struct {
char magic[3];
uint8_t version;
uint32_t sequence;
char description[SAVE_DESC_MAX + 1u];
uint8_t vars[AGI_NUM_VARS];
uint8_t flags[AGI_NUM_FLAGS];
@ -89,6 +98,11 @@ typedef struct {
uint8_t textFg;
uint8_t textBg;
char cursorChar;
bool holdKey;
bool simpleSaveOn;
char simpleSaveDesc[AGI_STRING_LEN + 1u];
bool priorityTableSet;
uint8_t priorityTable[AGI_HEIGHT];
uint8_t scriptCount;
AgiScriptEntryT script[AGI_SCRIPT_MAX];
} SaveFileT;
@ -97,11 +111,23 @@ typedef struct {
// ----- Prototypes -----
static bool fileBuffer(void);
static bool isSimpleDesc(const char *desc);
static uint8_t pickSlot(const char *title, bool mustExist);
static bool readDescription(uint8_t slot, char *out);
static uint8_t pointerPick(const char *header, bool offerNamed);
static uint32_t newestSequence(void);
static bool readDescription(uint8_t slot, char *out, uint32_t *sequence);
static void simpleDesc(char *out);
static uint8_t simpleSlot(void);
static void slotName(uint8_t slot, char *out);
// ----- Strings -----
// set.simple's dialogs, worded as Sierra's.
static const char kSimpleRestore[] = "Use the arrow keys to move\n the pointer to your name.\nThen press ENTER\n";
static const char kSimpleFull[] = " Sorry, this disk is full.\nPosition pointer and press ENTER\n to overwrite a saved game\nor press ESC to cancel.\n";
// ----- Module state -----
static SaveFileT *gFile;
@ -119,6 +145,12 @@ static bool fileBuffer(void) {
}
// A save description that is set.simple's.
static bool isSimpleDesc(const char *desc) {
return strncmp(desc, gAgi->simpleSaveDesc, SAVE_DESC_MAX) == 0;
}
static uint8_t pickSlot(const char *title, bool mustExist) {
char text[(SAVE_SLOTS + 3u) * (AGI_TEXT_COLS + 1u)];
char desc[SAVE_DESC_MAX + 1u];
@ -129,7 +161,7 @@ static uint8_t pickSlot(const char *title, bool mustExist) {
// A window listing the slots; a digit picks one.
n = (size_t)snprintf(text, sizeof(text), "%s\n\n", title);
for (slot = 1u; slot <= SAVE_SLOTS && n < sizeof(text); slot++) {
if (!readDescription(slot, desc)) {
if (!readDescription(slot, desc, NULL)) {
strcpy(desc, "-");
}
n += (size_t)snprintf(text + n, sizeof(text) - n, "%u. %s\n", (unsigned)slot, desc);
@ -146,7 +178,7 @@ static uint8_t pickSlot(const char *title, bool mustExist) {
}
if (key >= (uint16_t)'1' && key <= (uint16_t)('0' + SAVE_SLOTS)) {
slot = (uint8_t)(key - '0');
if (!mustExist || readDescription(slot, desc)) {
if (!mustExist || readDescription(slot, desc, NULL)) {
break;
}
}
@ -156,8 +188,104 @@ static uint8_t pickSlot(const char *title, bool mustExist) {
}
// A slot's description, read from the start of its file.
static bool readDescription(uint8_t slot, char *out) {
// The highest sequence number of the saves on the disk.
static uint32_t newestSequence(void) {
char desc[SAVE_DESC_MAX + 1u];
uint32_t newest;
uint32_t sequence;
uint8_t slot;
newest = 0u;
for (slot = 1u; slot <= SAVE_SLOTS; slot++) {
if (readDescription(slot, desc, &sequence) && sequence > newest) {
newest = sequence;
}
}
return newest;
}
// The saves listed under a header, one chosen with a pointer (set.simple's
// dialogs), starting on the newest. With offerNamed, set.simple's description is listed too when
// no save has it, as slot 0: Sierra's Mother Goose ships a save by that
// name ("Start New Character") that starts the game afresh, which
// JoeyAGI, keeping its own save files, does by restoring nothing. The
// slot, or 0 for that entry or ESC.
static uint8_t pointerPick(const char *header, bool offerNamed) {
char text[(SAVE_SLOTS + POINTER_HEADER_LINES + 3u) * (AGI_TEXT_COLS + 1u)];
char desc[SAVE_DESC_MAX + 1u];
uint8_t slots[SAVE_SLOTS + 1u];
uint8_t count;
uint8_t current;
uint8_t first;
uint8_t slot;
uint16_t key;
uint32_t sequence;
uint32_t newest;
size_t n;
bool named;
const char *c;
// The entries start a line below the header's last.
first = 1u;
for (c = header; *c != '\0'; c++) {
if (*c == '\n') {
first++;
}
}
n = (size_t)snprintf(text, sizeof(text), "%s", header);
count = 0u;
current = 0u;
newest = 0u;
named = false;
for (slot = 1u; slot <= SAVE_SLOTS; slot++) {
if (readDescription(slot, desc, &sequence)) {
if (sequence > newest) {
newest = sequence;
current = count;
}
n += (size_t)snprintf(text + n, sizeof(text) - n, "\n - %s", desc);
slots[count++] = slot;
named = named || isSimpleDesc(desc);
}
}
if (offerNamed && !named) {
simpleDesc(desc);
n += (size_t)snprintf(text + n, sizeof(text) - n, "\n - %s", desc);
slots[count++] = 0u;
}
// A blank last line as wide as the box sets its size.
(void)snprintf(text + n, sizeof(text) - n, "\n%*s", POINTER_BOX_COLS, "");
if (count == 0u) {
return 0u;
}
textPrint(text, -1, -1, PICK_WIDTH);
for (;;) {
textWindowPut((uint8_t)(first + current), 0u, POINTER_ON);
key = inputWaitKey();
textWindowPut((uint8_t)(first + current), 0u, POINTER_OFF);
if (key == AGI_KEY_ESCAPE) {
slot = 0u;
break;
}
if (key == AGI_KEY_ENTER) {
slot = slots[current];
break;
}
if (key == AGI_KEY_UP) {
current = (uint8_t)((current + count - 1u) % count);
} else if (key == AGI_KEY_DOWN) {
current = (uint8_t)((current + 1u) % count);
}
}
textCloseWindow();
return slot;
}
// A slot's description and sequence number (unless NULL), read from the
// start of its file; false for no save of this version.
static bool readDescription(uint8_t slot, char *out, uint32_t *sequence) {
char name[SAVE_NAME_MAX];
FILE *fp;
char head[offsetof(SaveFileT, description) + SAVE_DESC_MAX + 1u];
@ -167,17 +295,51 @@ static bool readDescription(uint8_t slot, char *out) {
if (fp == NULL) {
return false;
}
if (fread(head, 1, sizeof(head), fp) != sizeof(head) || head[0] != SAVE_MAGIC_0 || head[1] != SAVE_MAGIC_1 || head[2] != SAVE_MAGIC_2) {
if (fread(head, 1, sizeof(head), fp) != sizeof(head) || head[0] != SAVE_MAGIC_0 || head[1] != SAVE_MAGIC_1 || head[2] != SAVE_MAGIC_2 || (uint8_t)head[offsetof(SaveFileT, version)] != SAVE_VERSION) {
fclose(fp);
return false;
}
fclose(fp);
memcpy(out, &head[offsetof(SaveFileT, description)], SAVE_DESC_MAX + 1u);
out[SAVE_DESC_MAX] = '\0';
if (sequence != NULL) {
memcpy(sequence, &head[offsetof(SaveFileT, sequence)], sizeof(*sequence));
}
return true;
}
// set.simple's description, cut to a save's.
static void simpleDesc(char *out) {
const char *end;
end = (const char *)memchr(gAgi->simpleSaveDesc, '\0', SAVE_DESC_MAX);
memset(out, 0, SAVE_DESC_MAX + 1u);
memcpy(out, gAgi->simpleSaveDesc, end != NULL ? (size_t)(end - gAgi->simpleSaveDesc) : SAVE_DESC_MAX);
}
// set.simple's save slot: the one saved under its description, else the
// first empty one; 0 when every slot holds another.
static uint8_t simpleSlot(void) {
char desc[SAVE_DESC_MAX + 1u];
uint8_t slot;
uint8_t fresh;
fresh = 0u;
for (slot = 1u; slot <= SAVE_SLOTS; slot++) {
if (readDescription(slot, desc, NULL)) {
if (isSimpleDesc(desc)) {
return slot;
}
} else if (fresh == 0u) {
fresh = slot;
}
}
return fresh;
}
static void slotName(uint8_t slot, char *out) {
(void)snprintf(out, SAVE_NAME_MAX, "AGISAVE%u", (unsigned)slot);
}
@ -191,7 +353,11 @@ bool restoreGame(void) {
uint8_t i;
uint16_t r;
if (gAgi->simpleSaveOn) {
slot = pointerPick(kSimpleRestore, true);
} else {
slot = pickSlot("Restore which game?", true);
}
if (slot == 0u) {
return false;
}
@ -234,6 +400,11 @@ bool restoreGame(void) {
gAgi->textFg = gFile->textFg;
gAgi->textBg = gFile->textBg;
gAgi->cursorChar = gFile->cursorChar;
gAgi->holdKey = gFile->holdKey;
gAgi->simpleSaveOn = gFile->simpleSaveOn;
memcpy(gAgi->simpleSaveDesc, gFile->simpleSaveDesc, sizeof(gAgi->simpleSaveDesc));
gAgi->priorityTableSet = gFile->priorityTableSet;
memcpy(gAgi->priorityTable, gFile->priorityTable, sizeof(gAgi->priorityTable));
// Rebuild the picture with no objects drawn, then the objects.
gAgi->scriptCount = gFile->scriptCount;
memcpy(gAgi->script, gFile->script, sizeof(gAgi->script));
@ -266,13 +437,7 @@ bool restoreGame(void) {
o->cycleCount = s->cycleCount;
o->cycleMode = s->cycleMode;
o->motion = s->motion;
o->loopFlag = s->loopFlag;
o->motionFlag = s->motionFlag;
o->targetX = s->targetX;
o->targetY = s->targetY;
o->savedStepSize = s->savedStepSize;
o->randomSteps = s->randomSteps;
o->followDistance = s->followDistance;
memcpy(o->parm, s->parm, sizeof(o->parm));
}
gfxDrawStatic();
jlSurfaceClear(jlStageGet(), AGI_COLOR_BLACK);
@ -292,6 +457,16 @@ void saveGame(void) {
uint8_t i;
uint16_t r;
if (gAgi->simpleSaveOn) {
slot = simpleSlot();
if (slot == 0u) {
slot = pointerPick(kSimpleFull, false);
}
if (slot == 0u) {
return;
}
simpleDesc(desc);
} else {
slot = pickSlot("Save in which slot?", false);
if (slot == 0u) {
return;
@ -300,6 +475,7 @@ void saveGame(void) {
if (!inputGetString(desc, SAVE_DESC_MAX, AGI_NO_POSITION, AGI_NO_POSITION, "Description: ")) {
return;
}
}
if (!fileBuffer()) {
return;
}
@ -308,6 +484,7 @@ void saveGame(void) {
gFile->magic[1] = SAVE_MAGIC_1;
gFile->magic[2] = SAVE_MAGIC_2;
gFile->version = SAVE_VERSION;
gFile->sequence = newestSequence() + 1u;
memcpy(gFile->description, desc, sizeof(desc));
memcpy(gFile->vars, gAgi->vars, sizeof(gFile->vars));
memcpy(gFile->flags, gAgi->flags, sizeof(gFile->flags));
@ -337,6 +514,11 @@ void saveGame(void) {
gFile->textFg = gAgi->textFg;
gFile->textBg = gAgi->textBg;
gFile->cursorChar = gAgi->cursorChar;
gFile->holdKey = gAgi->holdKey;
gFile->simpleSaveOn = gAgi->simpleSaveOn;
memcpy(gFile->simpleSaveDesc, gAgi->simpleSaveDesc, sizeof(gFile->simpleSaveDesc));
gFile->priorityTableSet = gAgi->priorityTableSet;
memcpy(gFile->priorityTable, gAgi->priorityTable, sizeof(gFile->priorityTable));
for (i = 0u; i < AGI_MAX_OBJECTS; i++) {
const AgiObjectT *o = &gAgi->objects[i];
SavedObjectT *s = &gFile->objects[i];
@ -356,13 +538,7 @@ void saveGame(void) {
s->cycleCount = o->cycleCount;
s->cycleMode = o->cycleMode;
s->motion = o->motion;
s->loopFlag = o->loopFlag;
s->motionFlag = o->motionFlag;
s->targetX = o->targetX;
s->targetY = o->targetY;
s->savedStepSize = o->savedStepSize;
s->randomSteps = o->randomSteps;
s->followDistance = o->followDistance;
memcpy(s->parm, o->parm, sizeof(s->parm));
}
gFile->scriptCount = gAgi->scriptCount;
memcpy(gFile->script, gAgi->script, sizeof(gFile->script));

View file

@ -32,7 +32,6 @@
#define NOTE_BYTES 5u
#define NOTE_END 0xFFFFu
#define TICKS_PER_SECOND 60u
#define MS_PER_SECOND 1000u
#define PSG_CLOCK_HZ 111860u
#define DIVISOR_HIGH_MASK 0x3Fu
#define DIVISOR_LOW_MASK 0x0Fu
@ -159,7 +158,7 @@ void soundPump(void) {
return;
}
now = jlMillisElapsed();
due = (uint32_t)((now - gTickStartMs) * TICKS_PER_SECOND / MS_PER_SECOND);
due = (uint32_t)((now - gTickStartMs) * TICKS_PER_SECOND / AGI_MS_PER_SECOND);
while (gTicksDone < due && gData != NULL) {
gTicksDone++;
allDone = true;

200
agiText.c
View file

@ -69,6 +69,8 @@
#define TEXT_ATTR_BG_SHIFT 4u
#define MENU_MARGIN 8
#define MENU_FRAME AGI_COLOR_BLACK
// No text cell (the pointer's last one before it is seen).
#define POINTER_NOWHERE (-1)
#define DOT_MASK_EVEN_ROW 0xAAu
#define DOT_MASK_ODD_ROW 0x55u
#define SHOW_OBJ_MARGIN 4
@ -81,6 +83,14 @@ typedef enum {
SPAN_RESTORE = 2
} SpanOpE;
// What the pointer is over while the menus are open.
typedef enum {
MENU_HIT_NONE = 0,
MENU_HIT_BAR = 1,
MENU_HIT_TITLE = 2,
MENU_HIT_ITEM = 3
} MenuHitE;
// ----- Prototypes -----
@ -91,9 +101,12 @@ static void copySpan(jlSurfaceT *dst, const jlSurfaceT *src, int16_t col, in
static void drawBox(int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t frame);
static uint8_t drawMenuBar(uint8_t current);
static void drawMenuItems(uint8_t menu, uint8_t current);
static uint8_t menuItemsCol(const AgiMenuT *m, uint8_t *outWidth);
static MenuHitE menuUnderPointer(uint8_t open, uint8_t *menu, uint8_t *item);
static void drawText(uint8_t row, uint8_t col, const char *text, uint8_t fg, uint8_t bg);
static void echoCell(char ch, uint8_t fg, uint8_t bg);
static void expandInto(const char *src, char *dst, uint16_t dstCap, uint16_t *pos, uint8_t depth);
static uint8_t nextRow(uint8_t row);
static void openWindow(int16_t x, int16_t y, int16_t w, int16_t h);
static void putCell(uint8_t row, uint8_t col, char ch, uint8_t fg, uint8_t bg);
static void putGlyph(uint8_t row, uint8_t col, char ch, uint8_t fg, uint8_t bg, bool dotted);
@ -118,6 +131,9 @@ static int16_t gWinX;
static int16_t gWinY;
static int16_t gWinW;
static int16_t gWinH;
// Where textPrint put the window's first line (screen row and column).
static uint8_t gWinTextRow;
static uint8_t gWinTextCol;
// Wrapped text on its way to the screen (print and display draw it at once).
static char gLines[WINDOW_MAX_LINES][AGI_TEXT_COLS + 1u];
// Where the command line's echo draws next, as row * 40 + column.
@ -248,19 +264,7 @@ static void drawMenuItems(uint8_t menu, uint8_t current) {
uint8_t col;
m = &gAgi->menus[menu];
width = 0u;
for (i = 0u; i < m->itemCount; i++) {
uint8_t len;
len = (uint8_t)strlen(gAgi->menuItems[m->firstItem + i].text);
if (len > width) {
width = len;
}
}
col = m->col;
if ((uint16_t)col + width + 1u > AGI_TEXT_COLS) {
col = (uint8_t)(AGI_TEXT_COLS - width - 1u);
}
col = menuItemsCol(m, &width);
drawBox((int16_t)((int16_t)col * AGI_CELL_PX - MENU_MARGIN), (int16_t)((int16_t)MENU_ITEM_ROW * AGI_CELL_PX - MENU_MARGIN),
(uint16_t)((uint16_t)width * AGI_CELL_PX + 2u * MENU_MARGIN), (uint16_t)((uint16_t)m->itemCount * AGI_CELL_PX + 2u * MENU_MARGIN),
MENU_FRAME);
@ -290,6 +294,63 @@ static void drawText(uint8_t row, uint8_t col, const char *text, uint8_t fg, uin
// One character of the command line's echo at gEchoPos, which moves on;
// a position off the screen draws nothing.
// The column a menu's items start in (under its title, moved left to fit)
// and their width.
static uint8_t menuItemsCol(const AgiMenuT *m, uint8_t *outWidth) {
uint8_t i;
uint8_t width;
uint8_t len;
uint8_t col;
width = 0u;
for (i = 0u; i < m->itemCount; i++) {
len = (uint8_t)strlen(gAgi->menuItems[m->firstItem + i].text);
if (len > width) {
width = len;
}
}
col = m->col;
if ((uint16_t)col + width + 1u > AGI_TEXT_COLS) {
col = (uint8_t)(AGI_TEXT_COLS - width - 1u);
}
*outWidth = width;
return col;
}
// What the pointer is over: a menu title (*menu) or an item of the open
// menu (*item), or neither.
static MenuHitE menuUnderPointer(uint8_t open, uint8_t *menu, uint8_t *item) {
const AgiMenuT *m;
int16_t col;
int16_t row;
uint8_t i;
uint8_t left;
uint8_t width;
if (!mouseOver(&col, &row)) {
return MENU_HIT_NONE;
}
if (row == (int16_t)MENU_ROW) {
for (i = 0u; i < gAgi->menuCount; i++) {
m = &gAgi->menus[i];
if (col >= (int16_t)m->col && col < (int16_t)(m->col + strlen(m->name))) {
*menu = i;
return MENU_HIT_TITLE;
}
}
return MENU_HIT_BAR;
}
m = &gAgi->menus[open];
left = menuItemsCol(m, &width);
if (row >= (int16_t)MENU_ITEM_ROW && row < (int16_t)(MENU_ITEM_ROW + m->itemCount) && col >= (int16_t)left && col < (int16_t)(left + width)) {
*item = (uint8_t)(row - (int16_t)MENU_ITEM_ROW);
return MENU_HIT_ITEM;
}
return MENU_HIT_NONE;
}
static void echoCell(char ch, uint8_t fg, uint8_t bg) {
if (gEchoPos >= 0 && gEchoPos < SCREEN_CELLS) {
putCell((uint8_t)(gEchoPos / AGI_TEXT_COLS), (uint8_t)(gEchoPos % AGI_TEXT_COLS), ch, fg, bg);
@ -380,6 +441,13 @@ static void expandInto(const char *src, char *dst, uint16_t dstCap, uint16_t *po
// Save the stage under a window and draw its white box and red frame
// (x and w in AGI pixels, y and h in screen rows).
// The row below, or the bottom row again at the bottom (display() writes
// over it rather than scrolling).
static uint8_t nextRow(uint8_t row) {
return (row + 1u < AGI_TEXT_ROWS) ? (uint8_t)(row + 1u) : row;
}
static void openWindow(int16_t x, int16_t y, int16_t w, int16_t h) {
int16_t r;
@ -637,47 +705,60 @@ void textCloseWindow(void) {
}
// display(): text at a row and column in the current attribute, wrapped
// like a window's at the right edge; further lines start at the same
// column.
// display(): text at a row and column in the current attribute. As
// Sierra's, it is wrapped at the screen's width counted from the text, not
// from its column (King's Quest 1's 39-character copyright line at column
// 1 stays on one row); a wrapped line and a character past the right edge
// go on at column 0 of the next row, and the bottom row is written over
// rather than scrolled.
void textDisplay(uint8_t row, uint8_t col, const char *text) {
uint8_t fg;
uint8_t bg;
uint8_t count;
uint8_t width;
uint8_t i;
const char *c;
if (col >= AGI_TEXT_COLS) {
if (col >= AGI_TEXT_COLS || row >= AGI_TEXT_ROWS) {
return;
}
attrColors(&fg, &bg);
count = wrapText(text, (uint8_t)(AGI_TEXT_COLS - col), gLines, WINDOW_MAX_LINES, &width);
for (i = 0u; i < count && row < AGI_TEXT_ROWS; i++) {
drawText(row, col, gLines[i], fg, bg);
row++;
count = wrapText(text, AGI_TEXT_COLS, gLines, WINDOW_MAX_LINES, &width);
for (i = 0u; i < count; i++) {
if (i > 0u) {
row = nextRow(row);
col = 0u;
}
for (c = gLines[i]; *c != '\0'; c++) {
putCell(row, col, *c, fg, bg);
col++;
if (col >= AGI_TEXT_COLS) {
row = nextRow(row);
col = 0u;
}
}
}
}
// The command line: the prompt (string 0), what has been typed and the
// cursor, on the input row.
// cursor, on the input row, while input is on (off, nothing draws there).
void textDrawInputLine(void) {
char prompt[AGI_TEXT_COLS + 1u];
uint8_t fg;
uint8_t bg;
uint8_t col;
uint8_t i;
if (gAgi->textMode) {
if (gAgi->textMode || !gAgi->inputOn) {
return;
}
attrColors(&fg, &bg);
jlFillRect(jlStageGet(), 0, (int16_t)((int16_t)gAgi->inputRow * AGI_CELL_PX), (uint16_t)(AGI_TEXT_COLS * AGI_CELL_PX), AGI_CELL_PX, bg);
if (!gAgi->inputOn) {
return;
}
(void)textPrompt(prompt);
col = 0u;
for (i = 0u; gAgi->strings[0][i] != '\0' && col < AGI_TEXT_COLS; i++) {
putCell(gAgi->inputRow, col++, gAgi->strings[0][i], fg, bg);
for (i = 0u; prompt[i] != '\0' && col < AGI_TEXT_COLS; i++) {
putCell(gAgi->inputRow, col++, prompt[i], fg, bg);
}
for (i = 0u; i < gAgi->inputLen && col < AGI_TEXT_COLS; i++) {
putCell(gAgi->inputRow, col++, gAgi->inputLine[i], fg, bg);
@ -717,6 +798,7 @@ void textDrawStatus(void) {
// out can run back over the prompt); otherwise the echo starts from
// the end of what is typed.
void textEchoInput(uint8_t oldLen) {
char prompt[AGI_TEXT_COLS + 1u];
uint8_t fg;
uint8_t bg;
bool cursor;
@ -727,7 +809,7 @@ void textEchoInput(uint8_t oldLen) {
attrColors(&fg, &bg);
cursor = (gAgi->cursorChar != AGI_NO_CURSOR);
if (!agiQuirk(AGI_QUIRK_ECHO_STEPS_BACK)) {
gEchoPos = (int16_t)((int16_t)gAgi->inputRow * AGI_TEXT_COLS + (int16_t)strlen(gAgi->strings[0]) + oldLen + (cursor ? 1 : 0));
gEchoPos = (int16_t)((int16_t)gAgi->inputRow * AGI_TEXT_COLS + (int16_t)textPrompt(prompt) + oldLen + (cursor ? 1 : 0));
}
if (gAgi->inputLen > oldLen) {
if (cursor) {
@ -880,8 +962,15 @@ void textMenuAddItem(const char *name, uint8_t controller) {
uint8_t textMenuInput(void) {
uint8_t menu;
uint8_t item;
uint8_t hitMenu;
uint8_t hitItem;
int16_t lastCol;
int16_t lastRow;
int16_t col;
int16_t row;
uint16_t key;
uint8_t result;
MenuHitE hit;
jlSurfaceT *stage;
if (gAgi->menuCount == 0u) {
@ -900,13 +989,43 @@ uint8_t textMenuInput(void) {
if (menu >= gAgi->menuCount) {
menu = 0u;
}
lastCol = POINTER_NOWHERE;
lastRow = POINTER_NOWHERE;
for (;;) {
if (gWinSave != NULL) {
jlSurfaceCopy(stage, gWinSave);
}
(void)drawMenuBar(menu);
drawMenuItems(menu, item);
key = inputWaitKey();
// A key, or the pointer moving to another cell: over a title it
// opens that menu, over an item it lights it. A click on an item
// chooses it, on the bar does nothing more, elsewhere closes.
hit = MENU_HIT_NONE;
do {
key = inputPollKey();
hitMenu = menu;
hitItem = item;
hit = menuUnderPointer(menu, &hitMenu, &hitItem);
if (mouseOver(&col, &row) && (col != lastCol || row != lastRow)) {
lastCol = col;
lastRow = row;
if (hit == MENU_HIT_TITLE && hitMenu != menu) {
menu = hitMenu;
item = 0u;
break;
}
if (hit == MENU_HIT_ITEM && hitItem != item) {
item = hitItem;
break;
}
}
} while (key == AGI_KEY_NONE);
if (key == AGI_KEY_CLICK) {
if (hit == MENU_HIT_NONE) {
break;
}
key = (hit == MENU_HIT_ITEM) ? AGI_KEY_ENTER : AGI_KEY_NONE;
}
if (key == AGI_KEY_ESCAPE) {
break;
}
@ -975,6 +1094,17 @@ void textPrint(const char *text, int16_t row, int16_t col, uint8_t width) {
for (i = 0u; i < count; i++) {
drawText((uint8_t)(row + i), (uint8_t)col, gLines[i], WINDOW_TEXT_FG, WINDOW_BG);
}
gWinTextRow = (uint8_t)row;
gWinTextCol = (uint8_t)col;
}
// The command line's prompt into out (AGI_TEXT_COLS + 1 bytes): string 0
// with its %-codes expanded, as Sierra's draws it (Gold Rush's is "%g69",
// logic 0's ">"). Its length.
uint8_t textPrompt(char *out) {
textExpand(gAgi->strings[0], out, AGI_TEXT_COLS + 1u);
return (uint8_t)strlen(out);
}
@ -1027,3 +1157,13 @@ void textUpdateStatus(void) {
textDrawStatus();
}
}
// Text over the open window, at a line and column of what textPrint put
// there (a pointer moving down a list).
void textWindowPut(uint8_t line, uint8_t col, const char *text) {
if (!gAgi->windowOpen) {
return;
}
drawText((uint8_t)(gWinTextRow + line), (uint8_t)(gWinTextCol + col), text, WINDOW_TEXT_FG, WINDOW_BG);
}

198
agiVm.c
View file

@ -40,7 +40,6 @@
#define CODE_OR 0xFCu
#define MAX_ARGS AGI_SCRIPT_ARGS
#define MESSAGE_MAX 512u
#define DISTANCE_FAR 255u
#define DISTANCE_MAX 254u
// Logics calling logics nest only a few deep; a deeper chain is a logic
// calling itself round in a loop (KQ3's room 43 entered the wrong way),
@ -218,7 +217,32 @@
#define CMD_DIV_N 0xA7u
#define CMD_DIV_V 0xA8u
#define CMD_CLOSE_WINDOW 0xA9u
#define CMD_COUNT 0xAAu
// The commands of 2.425 and later, and AGI 3's (ScummVM's opcodes.cpp).
#define CMD_SET_SIMPLE 0xAAu
#define CMD_PUSH_SCRIPT 0xABu
#define CMD_POP_SCRIPT 0xACu
#define CMD_HOLD_KEY 0xADu
#define CMD_SET_PRI_BASE 0xAEu
#define CMD_DISCARD_SOUND 0xAFu
#define CMD_HIDE_MOUSE 0xB0u
#define CMD_ALLOW_MENU 0xB1u
#define CMD_SHOW_MOUSE 0xB2u
#define CMD_FENCE_MOUSE 0xB3u
#define CMD_GET_MSE_POSN 0xB4u
#define CMD_RELEASE_KEY 0xB5u
#define CMD_ADJ_EGO_MOVE_TO_XY 0xB6u
#define CMD_COUNT 0xB7u
// The interpreter versions commands differ by (agiRes.c's numbering:
// 2.917 is 2917, 3.002.149 is 3149), after ScummVM's opcodes.cpp and
// op_cmd.cpp.
#define VER_V2_FIRST 2000u // the v2 command set from here
#define VER_2089 2089u // quit takes no argument; print.at four from here
#define VER_2425 2425u // set.simple, push/pop.script, hold.key, set.pri.base from here ...
#define VER_2440 2440u // ... but 2.440 lacks them
#define VER_PRI_BASE_AGAIN 2936u // set.pri.base, missing after 2.425, again from here
#define VER_3086 AGI_VERSION_KQ4 // hide.mouse and hold.key take an argument
#define VER_3098 3098u // allow.menu and release.key from here
// Test commands (specification 6.2).
#define TEST_EQUALN 0x01u
@ -239,7 +263,8 @@
#define TEST_OBJ_IN_BOX 0x10u
#define TEST_CENTER_POSN 0x11u
#define TEST_RIGHT_POSN 0x12u
#define TEST_COUNT 0x13u
#define TEST_IN_MOTION_MOUSE 0x13u // AGI 3: ego walks to a click
#define TEST_COUNT 0x14u
typedef struct {
@ -248,11 +273,20 @@ typedef struct {
uint16_t pc;
uint8_t logic;
bool done;
bool acted; // a command ran since the last backward jump
} VmFrameT;
#if defined(AGI_TRACE)
AgiTraceFnT gAgiTrace = NULL;
#define TRACE(_f, _pc, _op, _args, _count, _result) do { if (gAgiTrace != NULL) { gAgiTrace((_f)->logic, (_pc), (_op), (_args), (_count), (_result)); } } while (0)
#else
#define TRACE(_f, _pc, _op, _args, _count, _result) do { (void)(_pc); } while (0)
#endif
// ----- Prototypes -----
static uint8_t actionArgs(uint8_t op);
static void cmdMisc(VmFrameT *f, uint8_t op, const uint8_t *a);
static void cmdObject(VmFrameT *f, uint8_t op, const uint8_t *a);
static void cmdText(VmFrameT *f, uint8_t op, const uint8_t *a);
@ -263,6 +297,7 @@ static void decryptMessages(uint8_t *raw, uint16_t length);
static bool evalCondition(VmFrameT *f);
static bool evalTest(VmFrameT *f, uint8_t test);
static void execute(VmFrameT *f);
static bool has2425Commands(void);
static AgiObjectT *object(uint8_t n);
static bool posnTest(uint8_t test, const uint8_t *a);
static void printMessage(const char *msg, int16_t row, int16_t col, uint8_t width);
@ -287,12 +322,13 @@ static const uint8_t kActionArgs[CMD_COUNT] = {
0, 0, 2, 5, 2, 1, 2, 0, 0, 3, 7, 7, 0, 0, 0, 0, // 0x70
0, 1, 3, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, // 0x80
1, 0, 0, 3, 3, 0, 3, 4, 4, 1, 5, 2, 1, 2, 0, 1, // 0x90
1, 0, 1, 0, 0, 2, 2, 2, 2, 0 // 0xA0
1, 0, 1, 0, 0, 2, 2, 2, 2, 0, 1, 0, 0, 0, 1, 1, // 0xA0
0, 1, 0, 4, 2, 0, 0 // 0xB0
};
// Argument bytes per test command (said is variable).
static const uint8_t kTestArgs[TEST_COUNT] = {
0, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 5, 1, 0, 0, 2, 5, 5, 5
0, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 5, 1, 0, 0, 2, 5, 5, 5, 0
};
@ -304,8 +340,32 @@ static uint8_t gCallDepth;
// ----- Internal helpers (alphabetical) -----
// The rest: system, screen, menu and arithmetic commands (0x7C..0xA9).
// A command's argument bytes in this game's interpreter: kActionArgs, but
// quit takes none on 2.089, print.at three below it, and King's Quest IV's
// hide.mouse and hold.key one.
static uint8_t actionArgs(uint8_t op) {
uint16_t v;
v = gAgi->game.interpreterVersion;
if (op == CMD_QUIT && v == VER_2089) {
return 0u;
}
if ((op == CMD_PRINT_AT || op == CMD_PRINT_AT_V) && v >= VER_V2_FIRST && v < VER_2089) {
return 3u;
}
if ((op == CMD_HIDE_MOUSE || op == CMD_HOLD_KEY) && v == VER_3086) {
return 1u;
}
return kActionArgs[op];
}
// The rest: system, screen, menu, arithmetic, and the later commands
// (0x7C..0xB6).
static void cmdMisc(VmFrameT *f, uint8_t op, const uint8_t *a) {
uint16_t span;
uint8_t r;
switch (op) {
case CMD_STATUS:
textInventory();
@ -328,18 +388,22 @@ static void cmdMisc(VmFrameT *f, uint8_t op, const uint8_t *a) {
textShowObject(gAgi->vars[a[0]]);
break;
case CMD_RANDOM:
if (a[1] >= a[0]) {
gAgi->vars[a[2]] = (uint8_t)(a[0] + jlRandomRange((uint16_t)(a[1] - a[0] + 1u)));
} else {
gAgi->vars[a[2]] = a[0];
}
// Sierra's: the number modulo the 16-bit span, so a maximum
// below the minimum takes the number as it is; so does the one
// span of 0 (a maximum one below), which Sierra's divides by.
span = (uint16_t)(a[1] - a[0] + 1u);
r = agiRandom();
gAgi->vars[a[2]] = (uint8_t)(a[0] + ((span == 0u) ? r : r % span));
break;
case CMD_PROGRAM_CONTROL:
gAgi->playerControl = false;
break;
case CMD_PLAYER_CONTROL:
// A walk to a click goes on (as ScummVM keeps it).
gAgi->playerControl = true;
if (gAgi->objects[AGI_EGO].motion != AGI_MOTION_MOUSE) {
gAgi->objects[AGI_EGO].motion = AGI_MOTION_NORMAL;
}
break;
case CMD_OBJ_STATUS_V:
agiObjectStatus(gAgi->vars[a[0]]);
@ -443,10 +507,52 @@ static void cmdMisc(VmFrameT *f, uint8_t op, const uint8_t *a) {
case CMD_CLOSE_WINDOW:
textCloseWindow();
break;
case CMD_SET_SIMPLE:
// Save and restore without asking, under string a[0].
if (has2425Commands()) {
strncpy(gAgi->simpleSaveDesc, a[0] < AGI_MAX_STRINGS ? gAgi->strings[a[0]] : "", AGI_STRING_LEN);
gAgi->simpleSaveDesc[AGI_STRING_LEN] = '\0';
gAgi->simpleSaveOn = gAgi->simpleSaveDesc[0] != '\0';
}
break;
case CMD_HOLD_KEY:
if (has2425Commands()) {
gAgi->holdKey = true;
}
break;
case CMD_RELEASE_KEY:
if (gAgi->game.interpreterVersion >= VER_3098) {
gAgi->holdKey = false;
}
break;
case CMD_SET_PRI_BASE:
if (gAgi->game.interpreterVersion == VER_2425 || gAgi->game.interpreterVersion >= VER_PRI_BASE_AGAIN) {
agiPrioritySetBase(a[0]);
}
break;
case CMD_ALLOW_MENU:
if (gAgi->game.interpreterVersion >= VER_3098) {
gAgi->menuDenied = a[0] == 0u;
}
break;
case CMD_HIDE_MOUSE:
gAgi->mouseHidden = true;
break;
case CMD_SHOW_MOUSE:
gAgi->mouseHidden = false;
break;
case CMD_FENCE_MOUSE:
memcpy(gAgi->mouseFence, a, sizeof(gAgi->mouseFence));
break;
case CMD_GET_MSE_POSN:
mousePosition(&gAgi->vars[a[0]], &gAgi->vars[a[1]]);
break;
default:
// init.disk, init.joy, toggle.monitor, script.size, set.game.id,
// log, trace.on, trace.info, set.upper.left, open.dialogue and
// close.dialogue have nothing to do here.
// log, trace.on, trace.info, set.upper.left, open.dialogue,
// close.dialogue, push.script and pop.script (debugging aids),
// discard.sound (the Apple IIgs's) and adj.ego.move.to.x.y
// without arguments (no game calls it) have nothing to do here.
break;
}
}
@ -480,6 +586,12 @@ static void cmdObject(VmFrameT *f, uint8_t op, const uint8_t *a) {
break;
case CMD_DRAW:
if (o != NULL && (o->flags & OBJ_DRAWN) == 0u && o->view != NULL) {
// v3 sets the loop and cel again first, so a view set since
// draws as its own loop and cel allow.
if (gAgi->game.interpreterVersion >= AGI_VERSION_V3_FIRST) {
objSetLoop(a[0], o->loop);
objSetCel(a[0], o->cel);
}
objFixPosition(a[0]);
o->prevX = o->x;
o->prevY = o->y;
@ -631,7 +743,7 @@ static void cmdObject(VmFrameT *f, uint8_t op, const uint8_t *a) {
const AgiObjectT *p = object(a[1]);
uint16_t d;
d = DISTANCE_FAR;
d = AGI_DISTANCE_FAR;
if (o != NULL && p != NULL && (o->flags & OBJ_DRAWN) != 0u && (p->flags & OBJ_DRAWN) != 0u) {
// From the middle of one baseline to the middle of the
// other, as Sierra's interpreter measures it.
@ -643,7 +755,7 @@ static void cmdObject(VmFrameT *f, uint8_t op, const uint8_t *a) {
d = DISTANCE_MAX;
}
}
gAgi->vars[a[2]] = (uint8_t)d;
gAgi->vars[a[2]] = agiPatchDistance(a[2], (uint8_t)d);
break;
}
case CMD_STOP_CYCLING:
@ -667,7 +779,7 @@ static void cmdObject(VmFrameT *f, uint8_t op, const uint8_t *a) {
case CMD_REVERSE_LOOP:
if (o != NULL) {
o->cycleMode = (op == CMD_END_OF_LOOP) ? AGI_CYCLE_END_LOOP : AGI_CYCLE_REVERSE_LOOP;
o->loopFlag = a[1];
o->parm[PARM_LOOP_FLAG] = a[1];
gAgi->flags[a[1]] = 0u;
o->flags = (uint16_t)(o->flags | OBJ_CYCLING);
if ((o->flags & OBJ_DRAWN) != 0u) {
@ -723,17 +835,13 @@ static void cmdObject(VmFrameT *f, uint8_t op, const uint8_t *a) {
break;
case CMD_FOLLOW_EGO:
if (o != NULL) {
o->motion = AGI_MOTION_FOLLOW;
o->randomSteps = 0u;
o->followDistance = (a[1] > o->stepSize) ? a[1] : o->stepSize;
o->motionFlag = a[2];
gAgi->flags[a[2]] = 0u;
objFollowEgo(a[0], a[1], a[2]);
}
break;
case CMD_WANDER:
// The count is what the shared bytes hold, as in Sierra's.
if (o != NULL) {
o->motion = AGI_MOTION_WANDER;
o->randomSteps = 0u;
if (a[0] == AGI_EGO) {
gAgi->playerControl = false;
}
@ -885,8 +993,9 @@ static void cmdText(VmFrameT *f, uint8_t op, const uint8_t *a) {
}
break;
case CMD_PREVENT_INPUT:
// The line goes black; nothing redraws it until accept.input.
gAgi->inputOn = false;
textDrawInputLine();
textClearLines(gAgi->inputRow, gAgi->inputRow, AGI_COLOR_BLACK);
break;
case CMD_ACCEPT_INPUT:
gAgi->inputOn = true;
@ -1014,6 +1123,7 @@ static void cmdVars(VmFrameT *f, uint8_t op, const uint8_t *a) {
saveScriptAdd(op, v[a[0]]);
break;
case CMD_SHOW_PIC:
gAgi->flags[FLAG_WINDOW_STAYS] = 0u;
gAgi->windowOpen = false;
gfxShowPic();
break;
@ -1171,6 +1281,7 @@ static bool evalTest(VmFrameT *f, uint8_t test) {
case TEST_OBJ_IN_ROOM: return gAgi->itemRoom[a[0]] == gAgi->vars[a[1]];
case TEST_CONTROLLER: return gAgi->controllers[a[0]] != 0u;
case TEST_HAVE_KEY: return inputHaveKey();
case TEST_IN_MOTION_MOUSE: return gAgi->objects[AGI_EGO].motion == AGI_MOTION_MOUSE;
case TEST_COMPARE_STRINGS:
return a[0] < AGI_MAX_STRINGS && a[1] < AGI_MAX_STRINGS && compareStrings(gAgi->strings[a[0]], gAgi->strings[a[1]]);
default: return posnTest(test, a);
@ -1182,10 +1293,13 @@ static bool evalTest(VmFrameT *f, uint8_t test) {
static void execute(VmFrameT *f) {
uint8_t op;
uint8_t a[MAX_ARGS];
uint8_t count;
uint8_t i;
uint16_t skip;
uint16_t at;
while (!f->done && !gAgi->abortLogic) {
at = f->pc;
op = readByte(f);
if (f->done) {
break;
@ -1195,6 +1309,7 @@ static void execute(VmFrameT *f) {
cond = evalCondition(f);
skip = readWord(f);
TRACE(f, at, op, NULL, 0u, cond ? 1 : 0);
if (!cond && !f->done) {
f->pc = (uint16_t)(f->pc + skip);
}
@ -1203,16 +1318,32 @@ static void execute(VmFrameT *f) {
if (op == CODE_GOTO) {
skip = readWord(f);
f->pc = (uint16_t)(f->pc + skip);
// A loop of tests alone waits on what only time, keys or sound
// change (Manhunter 1's "v11 = 0; while (v49 > v11)"); in
// Sierra's interpreter the timer interrupt moves them on as it
// spins, here a frame passes per turn.
if ((int16_t)skip < 0) {
if (!f->acted) {
agiIdle();
}
f->acted = false;
}
continue;
}
if (op >= CMD_COUNT || kActionArgs[op] > MAX_ARGS) {
jlLogF("logic %u: unknown command 0x%02X at %u", (unsigned)f->logic, (unsigned)op, (unsigned)(f->pc - 1u));
TRACE(f, at, op, NULL, 0u, -1);
f->done = true;
break;
}
for (i = 0u; i < kActionArgs[op]; i++) {
// Arguments a version leaves out read as 0 (quit on 2.089).
count = actionArgs(op);
memset(a, 0, sizeof(a));
for (i = 0u; i < count; i++) {
a[i] = readByte(f);
}
TRACE(f, at, op, a, count, -1);
f->acted = true;
if (op <= CMD_SHOW_PRI_SCREEN) {
cmdVars(f, op, a);
} else if (op <= CMD_UNBLOCK) {
@ -1226,6 +1357,13 @@ static void execute(VmFrameT *f) {
}
// set.simple, push.script, pop.script, hold.key: 2.425 and later, but not
// 2.440 (ScummVM's op_cmd.cpp).
static bool has2425Commands(void) {
return gAgi->game.interpreterVersion >= VER_2425 && gAgi->game.interpreterVersion != VER_2440;
}
static AgiObjectT *object(uint8_t n) {
if (n >= gAgi->objectCount) {
return NULL;
@ -1267,10 +1405,14 @@ static void printMessage(const char *msg, int16_t row, int16_t col, uint8_t widt
textExpand(msg, text, sizeof(text));
textPrint(text, row, col, width);
if (gAgi->flags[FLAG_WINDOW_STAYS] == 0u) {
// f15 leaves one window up without waiting, then goes back (Larry's
// quiz sets it for each question; the thanks after Alt+X waits).
if (gAgi->flags[FLAG_WINDOW_STAYS] != 0u) {
gAgi->flags[FLAG_WINDOW_STAYS] = 0u;
return;
}
(void)inputWaitPrintClose(gAgi->vars[VAR_WINDOW_TIMER]);
textCloseWindow();
}
}
@ -1385,6 +1527,7 @@ void vmCallLogic(uint8_t logicId) {
frame.pc = gAgi->logicScanStart[logicId];
frame.logic = logicId;
frame.done = false;
frame.acted = false;
caller = gAgi->currentLogic;
gAgi->currentLogic = logicId;
gCallDepth++;
@ -1431,7 +1574,10 @@ bool vmLoadLogic(uint8_t logicId) {
jlFree(raw);
return false;
}
// An LZW-packed v3 logic keeps its messages in the clear.
if (!agiResPacked(&gAgi->game, AGI_RES_LOGIC, logicId)) {
decryptMessages(raw, length);
}
gAgi->logicRaw[logicId] = raw;
gAgi->logicLength[logicId] = length;
return true;

View file

@ -13,11 +13,21 @@ games/
```
`scripts/test-agi.sh` runs the host tests against every subdirectory that
holds a `LOGDIR`, and `make iigs-game-disk-agi` puts `kq3` (or
`AGI_GAME=NAME`) on the bootable IIgs volume. Everywhere else the
holds a `LOGDIR` (v2) or a `<prefix>DIR` (v3), and `make
iigs-game-disk-agi` puts `kq3` (or `AGI_GAME=NAME`) on the bootable IIgs
volume. Everywhere else the
interpreter reads the game from its `DATA` folder; `joey-run --data
games/kq3` copies a game there for one run.
Releases for other machines go one level down, in `machines/` (`kq1amiga`,
`kq1gs`, `kq1st`, `kq1mac`, and so on), where the host tests do not look
yet (PLAN.md, Phase 5).
`scripts/oracle.sh` plays a game's sessions on the Sierra interpreter in its
folder. A release that came without one can be given another release's of
the same version; PLAN.md's games table says which. A v3 interpreter carries
its game's file prefix, so it opens only that game's files.
## Required files per game
For an AGI v2 game directory the loader needs:
@ -31,9 +41,15 @@ For an AGI v2 game directory the loader needs:
- `OBJECT` - inventory table
- `WORDS.TOK` - parser dictionary
and it reads Sierra's interpreter version from `AGIDATA.OVL` when the
An AGI v3 game instead has one `<prefix>DIR` holding the four indexes and
`<prefix>VOL.0`..`<prefix>VOL.N` (`GRDIR` and `GRVOL.*` for Gold Rush);
the loader knows the prefixes of Sierra's v3 games (`KQ4`, `GR`, `MH`,
`MH2`, `BC`, `DM`). `OBJECT` and `WORDS.TOK` are the same.
The loader reads Sierra's interpreter version from `AGIDATA.OVL` when the
folder has it, to behave as that version did (see `agiQuirk`); without
it the interpreter behaves as 2.435, the version it was measured on.
it the interpreter behaves as 2.435, the version it was measured on (or,
for a v3 game, 3.002.149).
File names are uppercase by AGI convention; the loader looks them up
literally so the directory should match.

View file

@ -5,7 +5,7 @@
AGI_HOME := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
PROGRAMS += AGI
AGI_SRCS := $(addprefix $(AGI_HOME)/,agi.c agiRes.c agiPic.c agiView.c agiGfx.c agiObj.c agiText.c agiFont.c agiInput.c agiSound.c agiSave.c agiVm.c)
AGI_SRCS := $(addprefix $(AGI_HOME)/,agi.c agiRes.c agiPic.c agiView.c agiGfx.c agiObj.c agiText.c agiFont.c agiInput.c agiSound.c agiSave.c agiVm.c agiPatch.c agiMouse.c)
AGI_TITLE := AGI
AGI_HELP := A Sierra AGI interpreter. The files of the game it plays go in its DATA folder.

View file

@ -1,12 +1,15 @@
#!/usr/bin/env bash
# oracle.sh - play the test probes (tests/probes.py) and the King's Quest
# III sessions (tests/kq3/*.scr) on Sierra's own interpreter and on
# JoeyAGI, and compare the frames.
# oracle.sh - play the test probes (tests/probes.py) and the game sessions
# (tests/GAME/*.scr, for each game in games/GAME) on Sierra's own
# interpreter and on JoeyAGI, and compare the frames.
#
# Sierra's interpreter is the black box the probes were checked against:
# each probe game is written beside the interpreter files of the copy of
# King's Quest III in games/kq3 (AGI, AGIDATA.OVL, the *.OVL drivers,
# KQ3.COM -- never distributed), run in DOSBox Staging at 386SX-16 speed
# KQ3.COM -- never distributed), or of the game ORACLE_INTERP names. A
# session plays a copy of its own game with that game's interpreter,
# started by the launcher it shipped with (GAME.COM, SIERRA.COM, or the
# game's .EXE). Each runs in DOSBox Staging as a fast 386 (dosDrive.py)
# under a private Xvfb and fed the probe's script with xdotool; JoeyAGI
# plays the same script on the host (build/tests/agiHost, built by
# scripts/test-agi.sh). tests/tools/compareFrames.py then compares each
@ -15,38 +18,81 @@
# A probe whose script records (shake) is compared by what
# tests/tools/shakeMeasure.py reads out of the two recordings.
#
# DOSBox runs in real time and Sierra's interpreter starts a moment later
# than the host's virtual clock, so shots of things in motion can be a
# cycle or two apart; the probes that matter record their results into
# variables and print them, which does not depend on timing.
# DOSBox runs in real time. dosDrive.py keeps every step of a script at
# its own time from the moment DOSBox switches to the game's graphics mode
# (the host harness's frame 0), so the two sides stay in step however long
# the screenshots take; a shot of something in motion can still fall in a
# different part of a cycle. The probes that matter record their results
# into variables and print them, which does not depend on timing.
#
# The follow probe's second follower takes random detours, so its x2/y2
# rows differ from run to run on Sierra's side; everything else matches.
#
# Usage: scripts/oracle.sh [PROBE | kq3-SESSION ...] every probe and
# Usage: scripts/oracle.sh [PROBE | GAME-SESSION ...] every probe and
# session when none is named
# Env: ORACLE_INTERP=GAME runs the probes on games/GAME's interpreter
# (a v2 game; default kq3)
# Output: build/oracle/PROBE/{oracle,joey}/ and a diff image per shot
# Needs: games/kq3 with Sierra's interpreter, Xvfb, xdotool, python3 +
# PIL, JoeyLib's DOSBox Staging (./install.sh install emulators)
# Needs: games/kq3 (or games/ORACLE_INTERP) with Sierra's interpreter,
# Xvfb, xdotool, python3 + PIL, JoeyLib's DOSBox Staging
# (./install.sh install emulators)
set -uo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
JOEYLIB=$(cd "${JOEYLIB:-$repo/../joeylib}" && pwd) || { echo "oracle: no JoeyLib checkout (set JOEYLIB)" >&2; exit 2; }
export JOEYLIB
sierra=$repo/games/kq3
games=$repo/games
sierra=$games/${ORACLE_INTERP:-kq3}
tools=$repo/tests/tools
build=$repo/build/oracle
agiHost=$repo/build/tests/agiHost
parallel=${ORACLE_JOBS:-4}
[[ -f $sierra/AGI && -f $sierra/KQ3.COM ]] || { echo "oracle: needs Sierra's interpreter in games/kq3" >&2; exit 2; }
# The launcher Sierra shipped with game $2 (games/$2) in folder $1. A v3
# game runs as <prefix>.COM, the name its installer gives SIERRA.COM (the
# interpreter takes its file prefix, GR of GRDIR, from it), so in a copy
# of one that file is made.
launcher() {
local dir=$1
local id=$2
local exe
local d
if [[ ! -f $dir/LOGDIR && -f $dir/SIERRA.COM ]]; then
for d in "$dir"/*DIR; do
if [[ -f $d ]]; then
cp "$dir/SIERRA.COM" "${d%DIR}.COM"
basename "${d%DIR}.COM"
return 0
fi
done
fi
for exe in "$dir/${id^^}.COM" "$dir/SIERRA.COM" "$dir"/*.[Ee][Xx][Ee]; do
if [[ -f $exe ]]; then
basename "$exe"
return 0
fi
done
return 1
}
[[ -f $sierra/AGIDATA.OVL ]] && launcher "$sierra" "$(basename "$sierra")" >/dev/null || { echo "oracle: needs Sierra's interpreter in $sierra" >&2; exit 2; }
for tool in Xvfb xdotool python3; do
command -v "$tool" >/dev/null || { echo "oracle: needs $tool" >&2; exit 2; }
done
[[ -x $agiHost ]] || "$repo/scripts/test-agi.sh" font >/dev/null || true
[[ -x $agiHost ]] || { echo "oracle: build/tests/agiHost missing (scripts/test-agi.sh builds it)" >&2; exit 1; }
sessions=$(for f in "$repo"/tests/kq3/*.scr; do [[ -f $f ]] && echo "kq3-$(basename "${f%.scr}")"; done)
# Every session of a game that ships Sierra's interpreter (AGIDATA.OVL;
# Space Quest 0's is a Windows program and has no part here), but those
# that use the mouse, which Sierra's DOS interpreters have none of, and
# those marked "# JoeyAGI only" (where JoeyAGI skips copy protection).
sessions=$(for f in "$repo"/tests/*/*.scr; do
g=$(basename "$(dirname "$f")")
[[ -f $f && -f $games/$g/AGIDATA.OVL ]] || continue
grep -qE '^((mouse|click)( |$)|# JoeyAGI only)' "$f" && continue
echo "$g-$(basename "${f%.scr}")"
done)
probes=${*:-$(python3 "$repo/tests/probes.py" list) $sessions}
mkdir -p "$build"
@ -56,19 +102,26 @@ one() {
local game=$build/$name/game
local run
local script
local exe
rm -rf "${build:?}/$name"
mkdir -p "$build/$name/oracle" "$build/$name/joey"
if [[ $name == kq3-* ]]; then
cp -r "$sierra" "$game"
script=$repo/tests/kq3/${name#kq3-}.scr
script=$repo/tests/${name%%-*}/${name#*-}.scr
if [[ $name == *-* && -f $script ]]; then
cp -r "$games/${name%%-*}" "$game"
exe=$(launcher "$game" "${name%%-*}")
else
python3 "$repo/tests/probes.py" build "$build/$name/.probe" "$name" || return 1
mv "$build/$name/.probe/$name" "$game"
rmdir "$build/$name/.probe"
cp "$sierra"/AGI "$sierra"/*.OVL "$sierra"/HGC_FONT "$sierra"/KQ3.COM "$game/"
exe=$(launcher "$sierra" "$(basename "$sierra")")
cp "$sierra"/*.OVL "$sierra"/HGC_FONT "$sierra/$exe" "$game/"
if [[ -f $sierra/AGI ]]; then
cp "$sierra/AGI" "$game/"
fi
script=$game/probe.scr
fi
timeout -s KILL 900 python3 "$tools/dosDrive.py" "$game" KQ3.COM "$build/$name/oracle" "$script" \
timeout -s KILL 900 python3 "$tools/dosDrive.py" "$game" "$exe" "$build/$name/oracle" "$script" \
--set joysticktype=none > "$build/$name/oracle.log" 2>&1
run=$(mktemp -d "$build/run.XXXXXX")
ln -s "$game" "$run/DATA"
@ -76,8 +129,8 @@ one() {
(cd "$run" && timeout -s KILL 900 "$agiHost" "$script" "$build/$name/joey") > "$build/$name/joey.log" 2>&1
rm -rf "$run"
}
export -f one
export build repo sierra tools agiHost
export -f one launcher
export build repo games sierra tools agiHost
# The fonts first: every comparison reads text with them.
one font

View file

@ -12,16 +12,17 @@
# subsystem (pictures, windows, motion, control lines, priority,
# parser, menus, inventory, sound timing, ...), each played by its
# own script. Expected hashes in tests/expected/probes/.
# 3. With games/kq3 present, the KQ3 sessions in tests/kq3/*.scr (the
# opening, walking, typing, menus, debug teleports). Expected hashes
# in tests/expected/kq3/.
# 3. The game sessions: tests/GAME/*.scr, each played on games/GAME when
# that game is there (King's Quest III's opening, walking, typing,
# menus, and debug teleports; the other games' starts). Expected
# hashes in tests/expected/GAME/.
#
# Time inside a run is virtual (one frame per jlWaitVBL), so a run is
# exactly reproducible. Every run leaves its frames as PPMs in
# build/tests/out/NAME/.
#
# Usage: scripts/test-agi.sh [--update] [NAME...]
# NAME is a probe, kq3-SESSION or resources-GAME; --update
# NAME is a probe, GAME-SESSION or resources-GAME; --update
# rewrites the expected hashes from this run (check the frames
# first, e.g. with scripts/oracle.sh).
# Env: JOEYLIB the JoeyLib checkout (default ../joeylib)
@ -50,8 +51,10 @@ mkdir -p "$obj" "$out"
# ----- Build: JoeyLib's core and generic code with the host port -----
jlFlags=(-std=c99 -O1 -DJOEYLIB_PLATFORM_BLANK -I "$JOEYLIB/include" -I "$JOEYLIB/include/joey" -I "$JOEYLIB/src/core" -I "$JOEYLIB/src/codegen")
agiFlags=("${jlFlags[@]}" -Wall -Wextra -Werror -I "$repo" -I "$repo/tests")
engine=(agi agiRes agiPic agiView agiGfx agiObj agiText agiFont agiInput agiSound agiSave agiVm)
agiFlags=("${jlFlags[@]}" -Wall -Wextra -Werror -DAGI_TRACE -I "$repo" -I "$repo/tests")
# The engine's sources, as program.mk lists them for the ports.
engine=($(grep '^AGI_SRCS' "$repo/program.mk" | sed -e 's/.*,//' -e 's/)$//' -e 's/\.c//g'))
[[ ${#engine[@]} -gt 0 ]] || { echo "test-agi: no AGI_SRCS in program.mk" >&2; exit 1; }
# compile SRC OBJ FLAGS...: when the source, or any header, is newer.
compile() {
@ -148,7 +151,8 @@ play() {
echo
echo "--- resources ---"
for dir in "$repo"/games/*/; do
[[ -f $dir/LOGDIR ]] || continue
# A v2 game has a LOGDIR, a v3 one a <prefix>DIR.
[[ -f $dir/LOGDIR ]] || compgen -G "$dir*DIR" >/dev/null || continue
game=$(basename "$dir")
name=resources-$game
selected "$name" || continue
@ -178,20 +182,23 @@ for name in $(python3 "$repo/tests/probes.py" list); do
play "$name" "$build/probes/$name" "$build/probes/$name/probe.scr" "$expected/probes/$name.txt"
done
# ----- 3. King's Quest III -----
# ----- 3. The games -----
echo
echo "--- kq3 ---"
if [[ -f $repo/games/kq3/LOGDIR ]]; then
for scr in "$repo"/tests/kq3/*.scr; do
[[ -f $scr ]] || continue
session=$(basename "${scr%.scr}")
play "kq3-$session" "$repo/games/kq3" "$scr" "$expected/kq3/$session.txt"
done
else
echo " skipped: no games/kq3"
for dir in "$repo"/tests/*/; do
game=$(basename "$dir")
compgen -G "$dir*.scr" >/dev/null || continue
echo
echo "--- $game ---"
if [[ ! -d $repo/games/$game ]]; then
echo " skipped: no games/$game"
skipped=1
fi
continue
fi
for scr in "$dir"*.scr; do
session=$(basename "${scr%.scr}")
play "$game-$session" "$repo/games/$game" "$scr" "$expected/$game/$session.txt"
done
done
echo
echo "===== test-agi: $passed passed, $failed failed ====="

11
tests/agidemo1/start.scr Normal file
View file

@ -0,0 +1,11 @@
# The start: Demo 1's menu, Space Quest picked (1) and run (F9).
wait 4
shot d01
key 1
wait 1
shot d02
key F9
wait 6
shot d03
wait 6
shot d04

13
tests/agidemo2a/start.scr Normal file
View file

@ -0,0 +1,13 @@
# The start: "Press any key" (space: Return is set.key's, and reaches
# have.key only by luck of timing on Sierra's), the menu, and demo 1.
wait 4
shot d01
key space
wait 4
shot d02
key 1
key Return
wait 6
shot d03
wait 6
shot d04

13
tests/agidemo2b/start.scr Normal file
View file

@ -0,0 +1,13 @@
# The start: "Press any key" (space: Return is set.key's, and reaches
# have.key only by luck of timing on Sierra's), the menu, and demo 1.
wait 4
shot d01
key space
wait 4
shot d02
key 1
key Return
wait 6
shot d03
wait 6
shot d04

13
tests/agidemo2c/start.scr Normal file
View file

@ -0,0 +1,13 @@
# The start: "Press any key" (space: Return is set.key's, and reaches
# have.key only by luck of timing on Sierra's), the menu, and demo 1.
wait 4
shot d01
key space
wait 4
shot d02
key 1
key Return
wait 6
shot d03
wait 6
shot d04

12
tests/agidemo3/start.scr Normal file
View file

@ -0,0 +1,12 @@
# The start: "Press any key" (space, as for Demo 2), the menu, and demo 1.
wait 4
shot d01
key space
wait 4
shot d02
key 1
key Return
wait 6
shot d03
wait 6
shot d04

8
tests/bc/start.scr Normal file
View file

@ -0,0 +1,8 @@
# The start: the title and the credits, then Taran by Dallben's cottage.
wait 4
shot b01
wait 8
shot b02
key Return
wait 6
shot b03

9
tests/bcv210/start.scr Normal file
View file

@ -0,0 +1,9 @@
# The Black Cauldron 2.10 (AGI 3): the title and the credits, then Taran
# by Dallben's cottage.
wait 4
shot b01
wait 8
shot b02
key Return
wait 6
shot b03

10
tests/ddpst/start.scr Normal file
View file

@ -0,0 +1,10 @@
# Donald Duck's Playground 1.0A, the Atari ST release's files (eXo files
# them as DOS; no interpreter here, so JoeyAGI's alone): the title, the
# nephews, and Duckburg.
wait 4
shot d1
wait 8
shot d2
key Return
wait 6
shot d3

View file

@ -0,0 +1,4 @@
d01 25017b21
d02 bc5c1dd9
d03 4dbe2518
d04 04103f6a

View file

@ -0,0 +1,4 @@
d01 d1423a29
d02 ecc33127
d03 9b0d5a7b
d04 3392072b

View file

@ -0,0 +1,4 @@
d01 d1423a29
d02 68780fd3
d03 9b0d5a7b
d04 3392072b

View file

@ -0,0 +1,4 @@
d01 b332a93d
d02 26dbae01
d03 2687ee4d
d04 9fa769c5

View file

@ -0,0 +1,4 @@
d01 556337e1
d02 dd7304d5
d03 e367472f
d04 46e6df19

View file

@ -0,0 +1,3 @@
b01 32386daf
b02 abbb679b
b03 e6cf7f1a

View file

@ -0,0 +1,3 @@
b01 32386daf
b02 abbb679b
b03 e6cf7f1a

View file

@ -0,0 +1,3 @@
d1 149b9a3d
d2 7f427c91
d3 e7e063da

View file

@ -0,0 +1,4 @@
s1 5d188ae1
s2 f1fbd425
s3 31e24c9a
s4 e9568d9a

View file

@ -0,0 +1,4 @@
s1 6c9d1d93
s2 ff68162b
s3 5a454ac2
s4 a682c8fc

View file

@ -0,0 +1,5 @@
k01 b13ad04d
k02 3e3e97ad
k03 b80a0692
k04 f4ada91e
k05 a228bc50

View file

@ -0,0 +1,5 @@
k01 6c4d72fc
k02 1468a291
k03 41e7e7b8
k04 0194cfae
k05 41e7e7b8

View file

@ -0,0 +1,7 @@
m01 9e1e99da
m02 c0a82f5f
m03 86889a57
m04 c08deb47
m05 266b8a99
m06 787d7b35
m07 c354a030

View file

@ -1,8 +1,8 @@
o01 fddacd89
o02 b2742c3f
o01 56c612e7
o02 4db5e753
o03 74541ae3
o04 bf373e80
o05 bf373e80
o06 625d901d
o07 784af2b9
o08 6e6b8dd5
o06 b398e4e7
o07 f9d34c7d
o08 4e8ec131

View file

@ -2,11 +2,11 @@ g01 003016a5
g02 f5361366
g03 9b841c62
g04 975e862c
g05 af3ac138
g05 bd657c8b
g06 2d70e908
g07 0aa33f32
g08 081e7951
g09 287e4cb3
g07 633446b0
g08 6e2ff025
g09 534621a8
g10 eb63dfcb
g11 8f2418db
g12 ac9a41f5

View file

@ -1,11 +1,11 @@
s01 c7fa568e
s02 6275e293
s03 37836207
s04 490e452f
s01 4d68f0cc
s02 1a2917ed
s03 08b7e523
s04 bfda331b
s05 4e59a416
s06 155d1e0e
s07 180b604f
s08 ab92c46b
s07 54f4988f
s08 0623b111
s09 bb34ec9d
s10 83ac187a
s11 d6a89b3d

View file

@ -1,4 +1,4 @@
r20 0d79701a
r23 e2e3fed9
r26 7c7ae16c
r20 8a6c383a
r23 5fddb0c1
r26 7a6e95bc
r29 616f1479

View file

@ -0,0 +1,4 @@
o01 56c612e7
o02 4db5e753
o03 74541ae3
o04 bf373e80

View file

@ -0,0 +1,6 @@
s1 b3107bc3
s2 2acfb799
s3 56f8d2fb
s4 ff6daa07
s5 b51e0291
s6 dfe12332

View file

@ -0,0 +1,6 @@
s1 2e9906b7
s2 e9dd505f
s3 0a7bdd27
s4 ff6daa07
s5 b51e0291
s6 f7914390

View file

@ -0,0 +1,4 @@
k01 f4117ded
k02 4d1d5ced
k03 2d6c5bb6
k04 1cdc393e

View file

@ -0,0 +1,8 @@
l01 9961df51
l02 e3462831
l03 54ac58a5
l04 4839f56a
l05 13859150
l06 76a0e2d2
l07 300661d9
l08 24e226b9

View file

@ -0,0 +1,4 @@
s1 0c9307fd
s2 b35b29d9
s3 f17f55d5
s4 b60478bf

View file

@ -0,0 +1,4 @@
m01 ca482697
m02 d0672fc9
m03 7602973d
m04 0cd7da2b

View file

@ -0,0 +1,4 @@
m01 ca482697
m02 d0672fc9
m03 7602973d
m04 0cd7da2b

View file

@ -0,0 +1,6 @@
m01 40e67cb7
m02 93199e7f
m03 34e1c07e
m04 c840cad2
m05 cbddccdf
m06 2814beed

View file

@ -0,0 +1,5 @@
p01 f3cc67fd
p02 088d7363
p03 2dd5daef
p04 5f41010f
p05 2dd5daef

View file

@ -0,0 +1,5 @@
p01 f3cc67fd
p02 088d7363
p03 2dd5daef
p04 5f41010f
p05 2dd5daef

View file

@ -1 +1 @@
a 40833758
a 409665a3

View file

@ -0,0 +1 @@
a e933940c

View file

@ -0,0 +1,4 @@
s01 ca27402f
s02 8e5dbbe1
s03 0318b5e2
s04 03e81ffe

View file

@ -0,0 +1,6 @@
s01 9c9fd51d
s02 bae6fb93
s03 80d7df3a
s04 d23de88e
s05 25d119ac
s06 66c57e84

View file

@ -0,0 +1,5 @@
s01 7c76584e
s02 d7838181
s03 a47fc7d1
s04 3222151b
s05 160cfe5d

View file

@ -0,0 +1,5 @@
s01 d0cc275a
s02 d7838181
s03 39b92971
s04 2232343b
s05 160cfe5d

View file

@ -0,0 +1,4 @@
t01 9caf908f
t02 4e25d715
t03 1604cfa9
t04 69e3b0be

View file

@ -0,0 +1,4 @@
x01 89dbf9ef
x02 35925cc4
x03 799a81e5
x04 5fda1127

12
tests/gr/start.scr Normal file
View file

@ -0,0 +1,12 @@
# The start: the titles, then Jerrod in Brooklyn Heights, 1848, a walk
# from the opening, and the next screen.
wait 4
shot s1
wait 8
shot s2
key Return
wait 4
shot s3
key Return
wait 6
shot s4

15
tests/grv201/start.scr Normal file
View file

@ -0,0 +1,15 @@
# Gold Rush 2.01, 3.5-inch, the original (games/gr is a cracked copy):
# logic 129's speed test, then the titles, Brooklyn Heights, and the
# next screen.
# JoeyAGI only: Sierra's interpreter goes from the speed test to the
# claim jumper's quiz (rooms 125 to 128), which JoeyAGI skips (agiPatch.c).
wait 4
shot s1
wait 8
shot s2
key Return
wait 4
shot s3
key Return
wait 6
shot s4

View file

@ -11,6 +11,9 @@
// wait SECONDS let SECONDS of virtual time pass
// key NAME [HOLD] press key NAME (xdotool names) for HOLD seconds
// down NAME / up NAME hold / release a key
// mouse X Y attach a mouse, its pointer at screen pixel
// (X, Y) (the host has none until then)
// click press the left button as long as a key
// type TEXT press each character's key in turn, typing it
// say TEXT type TEXT, press Return, give the game a second
// to answer and clear its message windows
@ -175,6 +178,7 @@ static void hostShow(char *things);
static void hostState(const char *name);
static void hostStep(void);
static bool hostTermHolds(const HostTermT *t);
static void hostVmTrace(uint8_t logic, uint16_t pc, uint8_t op, const uint8_t *args, uint8_t argCount, int8_t result);
static bool hostWaitStep(void);
static bool hostWalkStep(void);
static void hostWritePpm(const char *path);
@ -221,6 +225,7 @@ static char gOutDir[HOST_PATH_MAX];
static uint32_t gFrame;
static uint32_t gResumeFrame;
static uint32_t gHoldUntil[JL_KEY_COUNT];
static uint32_t gClickUntil;
static bool gHeld[JL_KEY_COUNT];
static uint8_t gDisplay[JL_SURFACE_HEIGHT][JL_SURFACE_WIDTH][3];
// Characters still to type for the current 'type' line, one per press.
@ -272,6 +277,9 @@ static bool gWindowSeen;
static uint32_t gWindowSince;
// AGI_HOST_TRACE in the environment: log every key a walk presses.
static bool gTrace;
// AGI_HOST_VM_TRACE=FILE: every command and condition the VM runs, one line
// each (hostVmTrace).
static FILE *gVmTrace;
static uint16_t gGotoCount;
@ -716,6 +724,20 @@ static void hostStep(void) {
if (gHeld[key] && ch != 0u) {
jlInputCharPush(ch);
}
} else if (strcmp(op, "mouse") == 0) {
char *yArg;
yArg = arg + strcspn(arg, " \t");
if (*yArg == '\0') {
fprintf(stderr, "agiHost: mouse wants X Y in '%s'\n", gOpLine);
exit(2);
}
jlgMouseAttached = true;
jlgMouseX = (int16_t)atoi(arg);
jlgMouseY = (int16_t)atoi(yArg + 1);
} else if (strcmp(op, "click") == 0) {
gClickUntil = gFrame + HOST_KEY_HOLD_FRAMES;
gResumeFrame = gFrame + HOST_KEY_HOLD_FRAMES + HOST_KEY_GAP_FRAMES;
} else if (strcmp(op, "shot") == 0) {
hostDump(arg);
} else if (strcmp(op, "record") == 0) {
@ -871,6 +893,22 @@ static bool hostTermHolds(const HostTermT *t) {
// One frame of an until or a walk: true when it is over. A message
// window left open meanwhile is dismissed; the time limit runs on.
// One line of the VM trace: the time, the logic, the command's offset in
// it, the command (FF for a condition, with its result) and its arguments.
static void hostVmTrace(uint8_t logic, uint16_t pc, uint8_t op, const uint8_t *args, uint8_t argCount, int8_t result) {
uint8_t i;
fprintf(gVmTrace, "%7.2f L%03u @%05u %02X", (double)gFrame / (double)HOST_FRAME_HZ, (unsigned)logic, (unsigned)pc, (unsigned)op);
if (result >= 0) {
fprintf(gVmTrace, " = %d", (int)result);
}
for (i = 0u; i < argCount; i++) {
fprintf(gVmTrace, " %u", (unsigned)args[i]);
}
fputc('\n', gVmTrace);
}
static bool hostWaitStep(void) {
if (gWait == HOST_WAIT_UNTIL && hostCondHolds()) {
return true;
@ -1080,6 +1118,7 @@ void jlpInputPoll(void) {
for (k = 0u; k < (uint16_t)JL_KEY_COUNT; k++) {
jlgKeyState[k] = (uint8_t)((gHeld[k] || gFrame < gHoldUntil[k]) ? 1u : 0u);
}
jlgMouseButtonState[JL_MOUSE_BUTTON_LEFT] = (uint8_t)((gFrame < gClickUntil) ? 1u : 0u);
}
@ -1138,6 +1177,12 @@ bool hostScriptOpen(const char *scriptPath, const char *outDir) {
}
strncpy(gOutDir, outDir, sizeof(gOutDir) - 1u);
gTrace = getenv("AGI_HOST_TRACE") != NULL;
if (getenv("AGI_HOST_VM_TRACE") != NULL) {
gVmTrace = fopen(getenv("AGI_HOST_VM_TRACE"), "w");
if (gVmTrace != NULL) {
gAgiTrace = hostVmTrace;
}
}
// Line by line, so a run killed by a timeout still shows how far it got.
setvbuf(stdout, NULL, _IOLBF, 0);
return true;

16
tests/kq1/start.scr Normal file
View file

@ -0,0 +1,16 @@
# The start: the title and the credits, then Graham by the castle, a look
# around, and the window closed.
wait 4
shot k01
wait 8
shot k02
key Return
wait 6
shot k03
type look
key Return
wait 2
shot k04
clear
wait 1
shot k05

16
tests/kq2/start.scr Normal file
View file

@ -0,0 +1,16 @@
# The start: the title and the credits, then Graham on the beach, a look
# around, and the window closed.
wait 4
shot k01
wait 8
shot k02
key Return
wait 6
shot k03
type look
key Return
wait 2
shot k04
clear
wait 1
shot k05

28
tests/kq3/mouse.scr Normal file
View file

@ -0,0 +1,28 @@
# The mouse (decision 4; Sierra's DOS interpreter has none, so this is JoeyAGI's
# alone): the pointer, a click to walk, the menu bar clicked, Help lit by
# the pointer and clicked, and its screen closed by a click.
wait 4
key Return
wait 4
mouse 230 150
wait 0.5
shot m01
click
wait 3
shot m02
mouse 20 3
click
wait 1
shot m03
mouse 60 3
wait 0.5
shot m04
mouse 60 24
wait 0.5
shot m05
click
wait 2
shot m06
click
wait 1
shot m07

10
tests/kq3v214/start.scr Normal file
View file

@ -0,0 +1,10 @@
# The start of King's Quest III 2.14 (interpreter 2.936): the title, the
# credits over Manannan's face, and the house.
wait 3
shot o01
wait 7
shot o02
wait 7
shot o03
wait 13
shot o04

18
tests/kq4/start.scr Normal file
View file

@ -0,0 +1,18 @@
# The start of King's Quest IV 2.3 for DOS (3.002.086; no interpreter
# here, so JoeyAGI's alone): the title, the credits, the throne room, and
# Rosella on Tamir's beach (the manual question skipped, agiPatch.c).
wait 4
shot s1
key Return
wait 6
shot s2
wait 8
shot s3
key Return
wait 8
shot s4
wait 10
shot s5
key Return
wait 8
shot s6

18
tests/kq4gs/start.scr Normal file
View file

@ -0,0 +1,18 @@
# The start of the Apple IIgs release: the title, the credits, the
# throne room, and Rosella on Tamir's beach (the manual question skipped,
# agiPatch.c). Its interpreter is the IIgs one, so this is JoeyAGI's alone.
wait 4
shot s1
key Return
wait 6
shot s2
wait 8
shot s3
key Return
wait 8
shot s4
wait 10
shot s5
key Return
wait 8
shot s6

11
tests/kq6agi/start.scr Normal file
View file

@ -0,0 +1,11 @@
# The fan demake's start: its disclaimers and title.
wait 4
shot k01
wait 4
shot k02
key Return
wait 4
shot k03
key Return
wait 6
shot k04

28
tests/lsl1/start.scr Normal file
View file

@ -0,0 +1,28 @@
# The start: the title, the warning, the age, the first quiz question, and
# Alt+X, the game's own way past the quiz (a set.key that sets f109), into
# Larry outside Lefty's.
wait 4
shot l01
wait 8
shot l02
key Return
wait 4
shot l03
key Return
wait 4
shot l04
type 30
key Return
wait 2
shot l05
key Return
wait 2
shot l06
down alt
key x
up alt
wait 2
shot l07
key Return
wait 6
shot l08

12
tests/mh1/start.scr Normal file
View file

@ -0,0 +1,12 @@
# The start: the Orbs over New York, two years later, and day 1 on the
# Upper East Side.
wait 4
shot s1
wait 8
shot s2
key Return
wait 4
shot s3
key Return
wait 6
shot s4

13
tests/mh2/start.scr Normal file
View file

@ -0,0 +1,13 @@
# The start: the date, the skyline after the orbs pass (shots kept off
# the text screen's end, which falls anywhere in its last second), the
# globe, and the alley.
wait 2
shot m01
wait 8
shot m02
key Return
wait 4
shot m03
key Return
wait 6
shot m04

12
tests/mh2v303/start.scr Normal file
View file

@ -0,0 +1,12 @@
# Manhunter 2 3.03, 5.25-inch: the date, the skyline, the globe, and the
# alley.
wait 2
shot m01
wait 8
shot m02
key Return
wait 4
shot m03
key Return
wait 6
shot m04

19
tests/mmg/start.scr Normal file
View file

@ -0,0 +1,19 @@
# The start: the title, the list of players (set.simple's restore; Start
# New Character starts afresh), the name, the face, and the bedroom.
wait 4
shot m01
wait 8
shot m02
key Return
wait 6
shot m03
key Return
wait 4
shot m04
type Scott
key Return
wait 3
shot m05
key Return
wait 8
shot m06

16
tests/pq1/start.scr Normal file
View file

@ -0,0 +1,16 @@
# The start: the title, then Sonny in the briefing room, a look around,
# and the window closed.
wait 4
shot p01
wait 8
shot p02
key Return
wait 6
shot p03
type look
key Return
wait 2
shot p04
clear
wait 1
shot p05

16
tests/pq1v20g/start.scr Normal file
View file

@ -0,0 +1,16 @@
# Police Quest 2.0G (interpreter 2.917): the title, then Sonny in the
# briefing room, a look around, and the window closed.
wait 4
shot p01
wait 8
shot p02
key Return
wait 6
shot p03
type look
key Return
wait 2
shot p04
clear
wait 1
shot p05

View file

@ -402,6 +402,61 @@ def probeCounters():
return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=VIEWS), "wait 5\nshot a\n"
def fiveLoopView():
# Five loops of one cel each, a stripe on a different row in each.
loops = []
for loop in range(5):
art = ["1111111"] * 20
art[2 + loop * 3] = "ccccccc"
loops.append([cel(art, transparent=0)])
return view(loops)
def probeLoopTurn():
# When a moving object's loop follows its direction: a 4-loop view
# stepping every 3 cycles (A) and every cycle (B), and a 5-loop view
# (C), turned right, then left on cycle 4, then down on cycle 9.
N = 14
rows = [("A", 1, 40), ("B", 2, 80), ("C", 3, 120)]
def body(L):
def mk(o, viewNr, y):
L.animate_obj(o)
L.set_view(o, viewNr)
L.set_loop(o, 0)
L.set_cel(o, 0)
L.position(o, 80, y)
L.ignore_objs(o)
L.stop_cycling(o)
with L.If(("isset", F_NEW_ROOM)):
L.assignn(V_CYCLE_DELAY, 2)
showPic(L)
L.load_view(0)
L.load_view(4)
for var, val in ((203, DIR_RIGHT), (204, 3), (206, DIR_LEFT), (207, DIR_DOWN)):
L.assignn(var, val)
mk(1, 0, 40)
L.step_time(1, 204)
mk(2, 0, 80)
mk(3, 4, 120)
for o in (1, 2, 3):
L.set_dir(o, 203)
L.draw(o)
L.show_pic()
L.increment(V_CYCLE)
for when, var in ((4, 206), (9, 207)):
with L.If(("equaln", V_CYCLE, when)):
for o in (1, 2, 3):
L.set_dir(o, var)
with L.If(("equaln", V_CYCLE, N + 2)):
for t in rows:
L.erase(t[1])
series(L, rows, N, lambda L, t, v: L.current_loop(t[1], v))
views = dict(VIEWS)
views[4] = fiveLoopView()
return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=views), "wait 5\nshot a\n"
def probeRedraw():
# What erase/draw, set.view, set.loop, set.cel, animate.obj and position
# do to a moving object's position and counters.
@ -1402,6 +1457,7 @@ PROBES = {
"newroom": probeNewRoom,
"edgewalk": probeEdgeWalk,
"distance": probeDistance,
"loopturn": probeLoopTurn,
}

11
tests/sq0/start.scr Normal file
View file

@ -0,0 +1,11 @@
# The fan game's start: its titles, the station, and the first room. Its
# own interpreter is a Windows program, so this one is JoeyAGI's alone.
wait 4
shot s01
wait 8
shot s02
key Return
wait 4
shot s03
wait 4
shot s04

20
tests/sq1/start.scr Normal file
View file

@ -0,0 +1,20 @@
# The start: the title, the log-on (first and last name), and the alarm
# aboard the Arcada.
wait 4
shot s01
wait 8
shot s02
key Return
wait 4
shot s03
type Roger
key Return
wait 2
shot s04
type Wilco
key Return
wait 6
shot s05
clear
wait 2
shot s06

14
tests/sq2/start.scr Normal file
View file

@ -0,0 +1,14 @@
# The start: the title, the opening over the planet, and the log-on.
wait 4
shot s01
wait 8
shot s02
key Return
wait 4
shot s03
wait 8
shot s04
type Roger
key Return
wait 4
shot s05

15
tests/sq2v20f/start.scr Normal file
View file

@ -0,0 +1,15 @@
# Space Quest II 2.0F (interpreter 2.936): the title, the opening over the
# planet, and the log-on.
wait 4
shot s01
wait 8
shot s02
key Return
wait 4
shot s03
wait 8
shot s04
type Roger
key Return
wait 4
shot s05

12
tests/sqtlc/start.scr Normal file
View file

@ -0,0 +1,12 @@
# The fan game's start: the title, its two windows, and the station.
wait 4
shot t01
key Return
wait 2
shot t02
key Return
wait 2
shot t03
key Return
wait 6
shot t04

View file

@ -27,6 +27,10 @@
#define SOUND_VOICES 4u
#define SOUND_NOTE_BYTES 5u
#define SOUND_END_BYTE 0xFFu
#define SOUND_TYPE_BYTES 2u
#define SOUND_IIGS_SAMPLE 1u
#define SOUND_IIGS_MIDI 2u
#define SOUND_PC 8u
#define RLE_COLOR_SHIFT 4u
#define RLE_COUNT_MASK 0x0Fu
#define MAX_CEL_PIXELS (255u * 255u)
@ -41,7 +45,7 @@ static const char *const kTypeNames[AGI_RES_COUNT] = { "logic", "picture", "view
static uint32_t fnv(uint32_t hash, const uint8_t *p, uint32_t length);
static bool testLogic(uint16_t n);
static bool testPicture(uint16_t n, uint8_t *visual, uint8_t *priority);
static bool testSound(uint16_t n);
static bool testSound(uint16_t n, uint16_t *truncated);
static bool testView(uint16_t n, uint8_t *pixels);
@ -95,20 +99,37 @@ static bool testPicture(uint16_t n, uint8_t *visual, uint8_t *priority) {
}
// Every voice's notes must end (FF FF) inside the resource.
static bool testSound(uint16_t n) {
// Every voice's notes should end (FF FF) inside the resource. One that runs
// to the resource's end instead is listed as truncated, not malformed:
// King's Quest III 2.14 shipped its sound 36 cut to 1035 of its 2281 bytes,
// and the player ends a voice where the resource ends. A PC sound's
// first word is 8, its first voice's offset; anything else is another
// machine's sound and only listed: an Apple IIgs sample or MIDI (first word 1
// or 2; King's Quest IV's IIgs release has nothing else), or an unknown kind
// (Demo 3's PC data carries sounds 209 to 225 starting 0x0101, which no
// logic plays).
static bool testSound(uint16_t n, uint16_t *truncated) {
uint8_t *data;
uint16_t length;
uint16_t pos;
uint16_t notes[SOUND_VOICES];
uint16_t type;
uint8_t v;
bool cut;
data = agiResLoad(&gAgi->game, AGI_RES_SOUND, n, &length);
type = (data != NULL && length >= SOUND_TYPE_BYTES) ? (uint16_t)(data[0] | ((uint16_t)data[1] << 8)) : 0u;
if (data != NULL && type != SOUND_PC) {
printf("sound %u %s %u bytes\n", (unsigned)n, (type == SOUND_IIGS_SAMPLE) ? "iigs sample" : ((type == SOUND_IIGS_MIDI) ? "iigs midi" : "not a pc sound"), (unsigned)length);
jlFree(data);
return true;
}
if (data == NULL || length < SOUND_VOICES * 2u) {
fprintf(stderr, "sound %u: does not load\n", (unsigned)n);
jlFree(data);
return false;
}
cut = false;
for (v = 0u; v < SOUND_VOICES; v++) {
pos = (uint16_t)(data[v * 2u] | ((uint16_t)data[v * 2u + 1u] << 8));
notes[v] = 0u;
@ -117,13 +138,15 @@ static bool testSound(uint16_t n) {
notes[v]++;
}
if ((uint32_t)pos + 1u >= length) {
fprintf(stderr, "sound %u: voice %u runs off the end\n", (unsigned)n, (unsigned)v);
jlFree(data);
return false;
fprintf(stderr, "sound %u: voice %u runs to the end of the resource\n", (unsigned)n, (unsigned)v);
cut = true;
}
}
jlFree(data);
printf("sound %u notes %u %u %u %u\n", (unsigned)n, (unsigned)notes[0], (unsigned)notes[1], (unsigned)notes[2], (unsigned)notes[3]);
if (cut) {
(*truncated)++;
}
printf("sound %u%s notes %u %u %u %u\n", (unsigned)n, cut ? " truncated" : "", (unsigned)notes[0], (unsigned)notes[1], (unsigned)notes[2], (unsigned)notes[3]);
return true;
}
@ -193,6 +216,7 @@ int main(void) {
uint16_t n;
uint16_t counts[AGI_RES_COUNT];
uint16_t absent;
uint16_t truncated;
uint8_t t;
bool ok;
@ -219,6 +243,7 @@ int main(void) {
}
memset(counts, 0, sizeof(counts));
absent = 0u;
truncated = 0u;
ok = true;
for (n = 0u; n < AGI_MAX_RESOURCES; n++) {
for (t = 0u; t < AGI_RES_COUNT; t++) {
@ -243,12 +268,12 @@ int main(void) {
break;
case AGI_RES_SOUND:
default:
ok = testSound(n) && ok;
ok = testSound(n, &truncated) && ok;
break;
}
counts[t]++;
}
}
fprintf(stderr, "%u logics, %u pictures, %u views, %u sounds, %u absent%s\n", (unsigned)counts[AGI_RES_LOGIC], (unsigned)counts[AGI_RES_PIC], (unsigned)counts[AGI_RES_VIEW], (unsigned)counts[AGI_RES_SOUND], (unsigned)absent, ok ? "" : " -- MALFORMED (see above)");
fprintf(stderr, "%u logics, %u pictures, %u views, %u sounds, %u absent, %u truncated%s\n", (unsigned)counts[AGI_RES_LOGIC], (unsigned)counts[AGI_RES_PIC], (unsigned)counts[AGI_RES_VIEW], (unsigned)counts[AGI_RES_SOUND], (unsigned)absent, (unsigned)truncated, ok ? "" : " -- MALFORMED (see above)");
return ok ? 0 : 1;
}

View file

@ -11,6 +11,8 @@
# key NAME [HOLD] press xdotool key NAME for HOLD seconds (0.12)
# down NAME / up NAME hold / release a key
# type TEXT press each character's key in turn
# say TEXT type TEXT, press Return, give the game a second
# to answer, then clear
# shot NAME save the screen as OUTDIR/NAME.png
# clear press Return while a message window is showing
# record NAME start recording every frame (70 Hz) ...
@ -19,7 +21,16 @@
# is bound to F12 by a mapper file, and a mapper file replaces every
# default binding -- the keyboard and Ctrl+F5 too -- so a script that
# records can do nothing else.
#
# Time is the script's, as in tests/hostPort.c's virtual time: every step
# happens at its own time since the interpreter started (the moment DOSBox
# logs the switch to its graphics mode), a key taking KEY_HOLD + KEY_GAP, a
# cleared window CLEAR_GAP and a screenshot no time at all. What a step
# costs here (the capture, each xdotool call) delays at most the next one
# and never the rest of the script. A recording's stop is the exception:
# the schedule starts again once the video is written.
# Env: DOSBOX (default JoeyLib's toolchains/opt/dosbox/dosbox), JOEYLIB.
import datetime
import glob
import os
import ctypes
@ -36,6 +47,11 @@ import compareFrames
JOEYLIB = os.environ.get("JOEYLIB", os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../../joeylib"))
DOSBOX = os.environ.get("DOSBOX", os.path.join(JOEYLIB, "toolchains/opt/dosbox/dosbox"))
CONF = os.path.join(JOEYLIB, "scripts/dosbox-386sx16.conf")
# JoeyLib's 386SX-16 configuration, but a fast 386: Sierra's interpreter
# paces itself by the clock, so speed only shortens the time it spends
# drawing and loading, which the host harness spends none of (at 2200
# cycles/ms Black Cauldron's title picture alone put it 1.9 s behind).
CPU_CYCLES = "50000"
KEY_HOLD = 0.12
KEY_GAP = 0.08
CLEAR_GAP = 0.6
@ -47,6 +63,14 @@ VIDEO_POLLS = 50
VIDEO_POLL_SECONDS = 0.3
SHOT_POLL_SECONDS = 0.1
WINDOW_POLLS = 50
SAY_SETTLE = 1.0
# The interpreter's start: DOSBox's log line for a graphics mode, and the
# timestamp at the head of each log line.
START_MARK = "graphics mode"
START_POLLS = 100
START_POLL_SECONDS = 0.1
LOG_TIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f"
LOG_TIME_LEN = 23
# prctl option: a signal for the child when its parent dies.
PR_SET_PDEATHSIG = 1
KEYMAP = {" ": "space", ".": "period", ",": "comma", "'": "apostrophe", "-": "minus",
@ -82,10 +106,12 @@ class Dos:
with open(mapper, "w") as f:
f.write('hand_video "key %d"\n' % VIDEO_KEY_SDL_SCANCODE)
args += ["--set", "mapperfile=" + mapper]
args += ["--set", "cpu_cycles=" + CPU_CYCLES, "--set", "cpu_cycles_protected=" + CPU_CYCLES]
for s in (extraSet or []):
args += ["--set", s]
args += ["-c", "MOUNT C \"%s\"" % hostDir, "-c", "C:", "-c", command]
self.proc = subprocess.Popen(args, env=self.env, stdout=open(os.path.join(self.work, "dosbox.log"), "w"),
self.log = os.path.join(self.work, "dosbox.log")
self.proc = subprocess.Popen(args, env=self.env, stdout=open(self.log, "w"),
stderr=subprocess.STDOUT, preexec_fn=dieWithParent)
self.win = None
for _ in range(WINDOW_POLLS):
@ -96,7 +122,29 @@ class Dos:
if not self.win:
self.stop()
raise RuntimeError("no DOSBox window")
self.t0 = time.time()
self.t0 = self.interpreterStart()
self.due = 0.0
def interpreterStart(self):
# When the program switched to a graphics mode, from DOSBox's log
# (the host harness's frame 0); the window's appearance if it never
# says so.
for _ in range(START_POLLS):
with open(self.log, errors="replace") as f:
for line in f:
if START_MARK in line:
try:
return datetime.datetime.strptime(line[:LOG_TIME_LEN], LOG_TIME_FORMAT).timestamp()
except ValueError:
break
time.sleep(START_POLL_SECONDS)
print("no graphics mode in DOSBox's log: timing from the window", file=sys.stderr)
return time.time()
def sleepUntil(self, due):
delay = self.t0 + due - time.time()
if delay > 0:
time.sleep(delay)
def findWin(self):
r = subprocess.run(["xdotool", "search", "--name", "."], env=self.env, capture_output=True, text=True)
@ -110,14 +158,16 @@ class Dos:
subprocess.run(["xdotool"] + list(a), env=self.env, capture_output=True)
def key(self, name, hold=KEY_HOLD):
self.sleepUntil(self.due)
self.xdo("keydown", "--window", self.win, name)
time.sleep(hold)
self.sleepUntil(self.due + hold)
self.xdo("keyup", "--window", self.win, name)
time.sleep(KEY_GAP)
self.due += hold + KEY_GAP
def typeText(self, text):
for ch in text:
if ch.isupper():
self.sleepUntil(self.due)
self.xdo("keydown", "--window", self.win, "shift")
self.key(ch.lower())
self.xdo("keyup", "--window", self.win, "shift")
@ -125,6 +175,7 @@ class Dos:
self.key(KEYMAP.get(ch, ch))
def shot(self, name):
self.sleepUntil(self.due)
before = set(glob.glob(os.path.join(self.work, "cap", "*.png")))
self.xdo("key", "--window", self.win, "ctrl+F5")
for _ in range(SHOT_POLLS):
@ -138,6 +189,7 @@ class Dos:
return False
def record(self, name):
self.sleepUntil(self.due)
self.recording = name
self.xdo("key", "--window", self.win, VIDEO_KEY)
@ -154,6 +206,7 @@ class Dos:
if size == last:
shutil.move(videos[0], os.path.join(self.out, self.recording + ".avi"))
self.recording = None
self.t0 = time.time() - self.due
return True
last = size
print("record %s: no video" % self.recording, file=sys.stderr)
@ -168,8 +221,10 @@ class Dos:
os.remove(path)
if not showing:
return
# The Return is pressed within the gap, as on the host.
start = self.due
self.key("Return")
time.sleep(CLEAR_GAP)
self.due = start + CLEAR_GAP
def stop(self):
try:
@ -188,12 +243,18 @@ class Dos:
continue
op, _, arg = line.partition(" ")
if op == "wait":
time.sleep(float(arg))
self.due += float(arg)
self.sleepUntil(self.due)
elif op == "key":
parts = arg.split()
self.key(parts[0], float(parts[1]) if len(parts) > 1 else KEY_HOLD)
elif op == "type":
self.typeText(arg)
elif op == "say":
self.typeText(arg)
self.key("Return")
self.due += SAY_SETTLE
self.clearWindows()
elif op == "shot":
self.shot(arg)
elif op == "clear":
@ -203,8 +264,10 @@ class Dos:
elif op == "stop":
self.stopRecording()
elif op == "down":
self.sleepUntil(self.due)
self.xdo("keydown", "--window", self.win, arg)
elif op == "up":
self.sleepUntil(self.due)
self.xdo("keyup", "--window", self.win, arg)
else:
raise ValueError("bad script op " + op)

12
tests/xmas/start.scr Normal file
View file

@ -0,0 +1,12 @@
# The card: the greeting, the message prompt (Return for none), and the
# fireplace.
wait 4
shot x01
key Return
wait 2
shot x02
key Return
wait 6
shot x03
wait 8
shot x04