diff --git a/.gitignore b/.gitignore index 1ca0f70..6520aaf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ build/ games/* !games/README.md +# Oracle and JoeyAGI frames of the games (STATUS.md): pictures of Sierra's +# games, never committed. +screenshots/ + __pycache__/ # Editor / OS cruft diff --git a/README.md b/README.md index 3fec65c..315d129 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,29 @@ $ scripts/verify.sh host # or name the ones to run | Gate | What it proves | |------|----------------| -| `host` | The resource loader, the PICTURE and VIEW decoders, the LOGIC VM and the VM pipeline, built for the host against JoeyLib's headers and run on every game in `games/` (`scripts/test-agi.sh`) | +| `host` | The whole interpreter, built for the build machine on JoeyLib's headless port, plays scripted sessions and every frame asked for must hash as expected: every resource of every game in `games/` loads and decodes (KQ3's pictures hash as Sierra's interpreter draws them); twenty probe games built from scratch by `tests/probes.py` (pictures, windows, motion, control lines, priority, the parser, menus, inventory, sound timing, ...); and, with `games/kq3`, four KQ3 sessions (the opening, play, save/restore/restart, debug teleports) (`scripts/test-agi.sh`) | | `disk` | `AGI.2mg` with King's Quest III boots on a IIgs, launches the interpreter and draws | `scripts/verify.sh` exits 0 when every gate passed, 1 when one failed, and 3 when none failed but one was skipped (no game in `games/`, or the IIgs toolchain or emulator missing). + +The expected hashes live in `tests/expected/`; `scripts/test-agi.sh --update` +records new ones once a change has been checked. Every run leaves its frames +in `build/tests/out/`. + +## Checking against Sierra's interpreter + +```console +$ scripts/oracle.sh # every probe and KQ3 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. diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..066515c --- /dev/null +++ b/STATUS.md @@ -0,0 +1,527 @@ +# JoeyAGI status + +Where JoeyAGI stands against Sierra's own interpreter, subsystem by subsystem +and command by command, with the evidence. Written from a clean-room deep dive +(October 2026): the public AGI specifications for the formats and commands, +and Sierra's interpreter (King's Quest III 2.00, interpreter 2.435, in DOSBox +Staging at 386SX-16 speed) run as a black box for everything the +specifications leave open. No ScummVM, NAGI or Sarien source was read. + +## In short + +- **King's Quest III plays.** The opening runs in step with Sierra's (title, + credits over Manannan, the house, the story windows: checked with single + screenshots at 9, 17 and 84 seconds); play starts in the hall with walking, + typing, the parser and its unknown-word replies, the menus, the inventory + screen, the F1 help screen, save, restore, restart and KQ3's debug mode and + teleports all behaving as on Sierra's interpreter. Where frames still differ + it is in things that move with time or chance (Manannan's sparkles, the cat, + the chickens, wandering), because DOSBox runs in real time and the harness in + virtual time, and the two random generators differ. +- **Every picture is exact.** All 97 KQ3 pictures (visual and priority, 194 + frames) match Sierra's pixel for pixel, as does a synthetic picture using + every drawing action and all 16 pens, and a long diagonal that shows + Sierra's byte-sized line error (see "Interpreter versions and quirks"). +- **shake.screen is Sierra's,** measured frame by frame from a recording of + Sierra's interpreter and reproduced beat for beat with JoeyLib's existing + API (see "shake.screen"). +- **Sierra's window and input-line quirks are reproduced:** a print.at + window at column 0 or 1 is drawn and only partly erased as Sierra's + byte arithmetic does it, and set.cursor.char changed mid-line moves the + echo as on Sierra's. Both, and the picture-line quirk, are gated on the + interpreter version read from the game's AGIDATA.OVL. +- **23 probe games, 93 frames and one recording: 89 match Sierra + exactly.** The others are follow.ego's random detours (its deterministic + half matches) and the clock probe's four frames, which all show one + measurement: a 1-second timed window that closes one cycle (50 ms) later + than Sierra's (the 3-, 2- and half-second ones match). +- **All six ports build;** the IIgs disk boots and draws (it was blank when + the dive started), and the ST, Amiga and X68000 builds draw KQ3 correctly + (`screenshots/port-*.png`). +- **Gates:** `make check` passes: host 28/28 (resources, 23 probes, 4 KQ3 + sessions), disk PASS. + +Not verified: playing KQ3 to the end, death scenes, the later lands (Llewdor, +the ship, Daventry) beyond their pictures and a few teleports, audio by ear, +and speed on the 8 MHz machines. + +## How it was checked + +| Tool | What it does | +|------|--------------| +| `tests/hostPort.c`, `tests/agiHost.c` | The whole interpreter on the build machine (JoeyLib's BLANK port): virtual 60 Hz time, a scripted keyboard, every frame resolved through the palette and hashed (FNV-1a) or saved as PPM. Fully reproducible. | +| `tests/agiGame.py`, `tests/probes.py` | Builds AGI games from scratch (LOGIC assembler from the spec's command table, PICTURE byte lists, VIEW from text art, SOUND, WORDS.TOK, OBJECT). Each probe records what the interpreter did cycle by cycle into variables and prints them, so its frames do not depend on timing. | +| `scripts/oracle.sh` | Runs a probe (or a KQ3 session) on Sierra's interpreter in DOSBox under a private Xvfb, driven by xdotool, and on JoeyAGI, then compares every shot with `tests/tools/compareFrames.py` (EGA colours, text read with each interpreter's own font -- JoeyAGI's font is its own -- and Sierra's 40-column text mode compared cell by cell). A probe whose script says `record` is recorded instead: DOSBox's video capture (every 70 Hz frame) on Sierra's side, every frame on JoeyAGI's, compared by `tests/tools/shakeMeasure.py`. | +| `scripts/test-agi.sh` | The host gate: resources, probes and KQ3 sessions against `tests/expected/`. | + +A caution learned on the way: DOSBox's screenshot key (Ctrl+F5) reaches the +game as a key press, and KQ3's opening hurries along when it sees one. The KQ3 +checkpoints below were each taken with a single screenshot. + +## The state found + +The interpreter at the start (commit 96c72ca) was a VM/host split with many +pieces stubbed: `sound` set its flag at once; `save.game`, `restore.game`, +`status`, `show.obj`, `menu.input`, `obj.status.v` did nothing; +`restart.game` quit; object collisions and `block` were not modelled; +`posn` tests were approximations; ego stepped a fixed 2 pixels; and an argv +"starting room" let you skip "a title screen the VM can't yet advance +through". The IIgs disk gate passed, but on the IIgs the old engine was the +one drawing. + +Nearly every module was rewritten (agi.c, agiRes, agiPic, agiView, agiObj, +agiText, agiVm) or written new (agiGfx, agiFont, agiInput, agiSound, +agiSave), each behaviour settled against the specifications and then against +Sierra's interpreter. + +## Subsystems + +| Subsystem | Status | Evidence | +|-----------|--------|----------| +| Resources (directories, VOL, WORDS.TOK, OBJECT) | Works. Files read whole into buffers of their own size from the Memory Manager on the IIgs; a missing or empty directory just means none of that type. | `resources-kq3`: 125 logics, 97 pictures, 216 views, 34 sounds load and decode | +| 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 | +| 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) | +| 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` | +| 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 | +| Display on planar machines | Picture updates go out as colour runs through jlFillRect (two jlDrawPixel calls per AGI pixel before) | host frames identical either way; ST/Amiga/X68000 snapshots | + +## Bugs found and fixed in this dive, with causes + +1. **IIgs: blank screen.** Every resource came from `malloc`, and on the IIgs + the C heap is the small window left in the entry bank (1926 bytes with 22 KB + of BSS). Logic 0 could not be allocated, so nothing ran. All interpreter + memory now comes from jlAlloc (the Memory Manager); files are read into + buffers of their exact size; a view is one allocation; BSS fell from 22 KB + to 4 KB (byte-sized fill stack, one shared line buffer, the picture script + moved into the jlAlloc'd state). +2. **ST, Amiga, X68000: picture output.** Planar stages have no chunky bytes, + and the fallback drew two jlDrawPixel calls per AGI pixel. Now runs of one + colour per row through jlFillRect. (An 8x8 tile path was tried first and + came out striped: jlTileT's bytes are in each port's own layout.) +3. **Text attributes.** set.text.attribute in picture mode stores a background + of white (or black); text.screen clears to that; on the text screen the + colours are used as given; clear.lines and clear.text.rect on the text + screen take the colour as a PC attribute byte (so 0..15 clear to black). + The old rule (background | 8) was wrong. +4. **Sound on at start.** Sierra's interpreter sets f9 itself (its status line + reads "Sound:on" in a game that never touches f9). +5. **Input line.** Default row 23, not 22; no cursor before set.cursor.char; + typing draws only the changed cell, so text displayed on the input row stays + until Enter or accept.input redraws the line. +6. **Unknown words.** said() failed whenever v9 was set; Sierra keeps the + unknown word in the list, where any-word and rest-of-line match it, so + "open the door now" matches said(open, rol). A line of only ignored words + no longer sets f2, and v9 is cleared each cycle. +7. **Ego steering.** v6 is copied to ego's direction before and after logic 0 + in both control modes (the old code copied ego's direction into v6 under + player control, so set.dir moved ego); v6 set by a logic now moves ego that + same cycle. +8. **reposition / reposition.to** skip the object's step in that cycle. +9. **Sound flag** came after the longest voice; Sierra's comes after the first + voice. This alone put KQ3's opening seconds behind (its title waits on the + tune's flag). +10. **Restore** let the rest of the cycle run (objects stepped once past the + saved state) and cleared f12 before any logic saw it; KQ3's logic 0 tests + f12 after a restore to fix up its menu. +11. **Restart** wiped the menus and set.key mappings, but KQ3 builds both only + on a first start (f6 clear), so after a restart Esc and the function keys + did nothing. +12. **Resource memory** grew without bound (every logic and view ever loaded + stayed); now freed at new.room. +13. **Games without SNDDIR** (or any empty directory) failed to start. +14. **Frame order.** Each idle frame polled the keys, pumped the sound and + ticked the clock *before* waiting for the vertical blank, so a cycle that + started right after the wait saw them a frame stale: a short sound's flag + arrived a cycle late. The frame now passes first. +15. **have.key** let a frame pass every time a logic asked, and took any key + that arrived then -- so a probe asking once a cycle swallowed the arrow + key meant to steer ego. Now only a logic asking twice in one cycle + waits. +16. **shake.screen** was a pause. It now shakes as Sierra's does (below). +17. **print.at at column 0 or 1** drew a clipped window and erased it + whole; Sierra's interpreter draws and erases it displaced (below). +18. **Window save-under** went through jlSurfaceCopyRect, which widens a + copy to 16-pixel groups, so closing a window also put back up to 15 + stale pixels on each side: harmless while nothing beside an open window + changed, wrong for the column-1 window, whose own frame and text sit + there. Now exact: a chunky surface copies bytes (an AGI pixel each), a + planar one copies the whole groups and the pixels at the ends one by + one. Checked on all five machine ports against the host frame (0 + pixels differ on ST, Amiga, X68000, DOS and IIgs). +19. **set.cursor.char mid-line** echoed from the end of the typed text; + Sierra's echo keeps its own position (below). +20. **Picture lines** were exact to the fraction; Sierra's interpreter + carries the error in a byte, which a line long on both sides overflows + (below). No KQ3 picture has such a line. + +Fixed earlier in the dive (before the gates existed), all verified against +Sierra's interpreter: the picture decoder (line algorithm, fill semantics, +pens, splatter textures); window geometry and the wrap rule; display wrapping; +the status line; menus; the inventory layout; show.obj placement; add.to.pic +and its margin boxes; sprite priority masking; control-line blocking; object +collisions; the position-fixing spiral; motion and cycle counter timing; +move.obj arrival; follow.ego; set.view/set.loop resets of missing loops and +cels; animate.obj leaving step and cycle settings alone; %-codes in messages +(%v with |width, %s, %w, %0, %m, %g); the parser's longest match; the intro's +timing; v21 window timeouts; a guard for KQ3's room 43 call.v(0) recursion +(Sierra hangs there); Alt and Ctrl chords no longer type characters. + +## shake.screen + +Measured on Sierra's interpreter from DOSBox's video capture, every 70 Hz +VGA frame of probe games calling shake.screen(1), (2) and (3) (the `shake` +probe) and (64), (65) and (0) (one-off probes); `tests/tools/shakeMeasure.py` +reads the shakes out of a recording: + +- **What moves:** the whole screen -- picture, status line, displayed text + and the input line -- by exactly 8 screen pixels right (4 AGI pixels) and + 4 rows down, a plain shift; the uncovered strips are black. No other + offset or direction occurs. +- **A beat** is the moved screen for 4 frames, then the screen in place for + 4, locked to the vertical blank (114 ms at 70 Hz). +- **Beats:** 4 per unit: 1, 2, 3 gave 4, 8, 12 beats; 64 gave 256 and 65 + gave 260, so the count is 16 bits, and shake.screen(0) ran past 1090 + beats before the recording stopped (65536 beats, about two hours). +- **It blocks:** no cycles run and nothing animates until it ends. + +JoeyAGI (`gfxShakeScreen` in agiGfx.c, JoeyLib's existing API, no per-port +code): it copies the stage to one surface and builds a second with that +screen moved (8, 4) over black (a chunky surface moves its bytes; a planar +one draws the screen as one 40x25-tile sprite at (8, 4)); each beat copies +the moved one to the stage, presents and lets 4 display frames pass +(timed with jlMillisElapsed against jlFrameHz, so a slow copy is not added +on top), then the same with the screen in place. shake.screen(0) is 65536 +beats, as on Sierra's. If the two surfaces cannot be allocated the shake is +a pause of the same length. The host's recording reads the same as +Sierra's: 4, 8 and 12 beats, moved 4 frames, in place 4 frames, offset +(8, 4); the host gate pins every frame of it. + +**IIgs cost:** a phase is a 32 KB jlSurfaceCopy into the stage (about +108 ms) and a full-screen present (about 74 ms): 11 frames (183 ms) where +Sierra's takes 4, so a beat is 367 ms against Sierra's 133 ms at 60 Hz and +a shake lasts 2.75 times as long. The other ports were not timed. + +**A JoeyLib addition that would make it much better (not made):** a +display offset -- show the stage moved by (dx, dy), uncovered edges black +-- as one call (say `jlStagePresentOffset(dx, dy)`). Where the hardware +scrolls it is free: Amiga bitplane pointers plus BPLCON1, the X68000's +graphic scroll registers, the VGA CRTC start address; the ST (an STF's +video base moves in 256-byte steps) and the IIgs (no hardware scroll) would +present the stage shifted, one present's worth of copying instead of a +32 KB copy and a present -- about halving the IIgs's beat. + +## Interpreter versions and quirks + +KQ3's AGIDATA.OVL names its interpreter ("Adventure Game Interpreter", +"Version 2.435"); the AGI executable carries no readable version (2.00 is +KQ3's own version, shown by the game). At startup `detectVersion` +(agiRes.c) reads the version from AGIDATA.OVL, else from the AGI file, else +takes 2.435, the version measured, into `game.interpreterVersion`; +`agiQuirk()` answers from one table of version ranges (`kQuirkVersions`), +the one place per-version behaviour is decided. The IIgs disk now carries +AGIDATA.OVL, and `resources-kq3` checks KQ3 reads as 2435. + +Only one Sierra interpreter, 2.435, was at hand, so whether the quirks +differ between versions could not be measured: all three are on for 2.000 +to 2.999 (every version JoeyAGI plays), and narrowing a range is a one-line +change once another interpreter is measured. Off, each falls back to the +plain behaviour (a clipped window, an echo from the end of the typed text, +an exact line). + +- **AGI_QUIRK_WINDOW_BYTE_X -- print.at at column 0 or 1.** A window's box + is x = 4 * col - 5 AGI pixels, y = 8 * row - 5 screen rows, 4 * width + 10 + by 8 * lines + 10, the red frame one pixel in; its sides stop a row short + of the top and bottom lines. Saving the screen under it, the white fill, + the frame and putting the screen back all address the screen as one line + of 160-pixel rows with x in a byte (row * 160 + (x & 255)), each span + running on into the next row; the text is drawn where it belongs. At + column 1 (x = -1, so 255) the save, fill and restore land a row down at + x 95 and spill into the row below from x 0, while the frame (x + 1 = 0) + and the text are in place: the window opens with its white running off to + the right, and closing it erases only its leftmost 4 * width - 55 pixels, + leaving the rest of the frame and text on screen. At column 0 (x = 251) + the box and its top, bottom and left lines are drawn a row down, 91 in, + and the right side and the text stay in place. Columns 2 and on are not + affected. `winedge` probe: nine windows at columns 0 to 3, one to three + lines, on a coloured picture, open and closed: 18/18 frames identical. +- **AGI_QUIRK_ECHO_STEPS_BACK -- set.cursor.char mid-line.** The input line + echo keeps its own position: a redraw (Enter, accept.input) leaves it + after the prompt, the text and the cursor; with a cursor set, a typed + character first steps back one -- whether or not a cursor was drawn there + -- then draws itself and the cursor; Backspace steps back two and draws + the cursor and a blank (without a cursor: back one, a blank). So a cursor + set mid-line makes the next character overwrite the last one typed + (">w", then '*' set and "yz" typed, shows ">yz*" while the line holds + "wyz"), a cursor taken away stays on the line (">pq_" then "r" shows + ">pq_r"), and rubbing that line out with the cursor set runs back over + the prompt (three Backspaces leave just "*"). `cursor` probe: 20/20 + frames identical. +- **AGI_QUIRK_LINE_BYTE_ERROR -- picture lines** (found by the `winedge` + picture): the line's error term is a byte, so a line long on both sides + loses steps when it passes 255: (0,0) to (159,167) stalls two rows in ten + and ends at (134,167). No KQ3 picture has such a line (all 194 frames are + unchanged). + +## What remains, most important first + +1. **A full play-through.** KQ3 has not been finished on JoeyAGI. The rooms, + puzzles, spells, deaths and endings past the house are untested beyond + their resources decoding and a few teleports. +2. **Speed on the 8 MHz machines.** Not measured. The run-length output is + much faster than before; picture decoding and flood fill run in C. +3. **Audio by ear.** Note timing and the flag are exact; how the four voices + and noise sound on each port's JoeyLib audio has not been judged. +4. **The IIgs beyond booting.** The disk gate proves it boots and draws; the + opening was checked by framebuffer dumps, play on the IIgs was not. +5. **Save/restore look.** JoeyAGI has its own slot window (slots 1-9, a + description) instead of Sierra's path prompt and list, and its own file + format, which is per machine type (byte order and padding). +6. **Random sequences** differ from Sierra's (wander, follow.ego detours, + KQ3's Manannan schedule): expected, not a bug. +7. **Other interpreter versions.** Only 2.435 was available to measure; the + quirks are assumed for every 2.x interpreter (see below). +8. **The font** is JoeyAGI's own, not the PC's ROM font. +9. **shake.screen on the IIgs** takes 2.75 times as long as on Sierra's + (see below); a JoeyLib display offset would fix it. +10. **The flood fill's seed stack** (1024 seeds) would truncate a fill that + needs more; no KQ3 picture does. + +## Commands + +Every AGI v2 command is implemented. "KQ3 uses" counts the command in KQ3's +logics; "Probe" marks commands the probe games exercise against Sierra's +interpreter (everything KQ3 uses is also exercised by the KQ3 sessions or +checkpoints). FIXED marks behaviour corrected in this dive's later half; +the earlier rewrite touched nearly every command. + +| # | Command | KQ3 uses | Probe | Status | +|---|---------|---------:|:-----:|--------| +| 0x00 | return | 262 | yes | works | +| 0x01 | increment | 186 | yes | works | +| 0x02 | decrement | 144 | | works | +| 0x03 | assignn | 2659 | yes | works | +| 0x04 | assignv | 111 | yes | works | +| 0x05 | addn | 106 | | works | +| 0x06 | addv | 10 | | works | +| 0x07 | subn | 29 | yes | works | +| 0x08 | subv | 7 | | works | +| 0x09 | lindirectv | 1 | | works | +| 0x0A | rindirect | 1 | | works | +| 0x0B | lindirectn | 1 | | works | +| 0x0C | set | 923 | yes | works | +| 0x0D | reset | 737 | yes | works | +| 0x0E | toggle | 12 | | works | +| 0x0F | set.v | 4 | | works | +| 0x10 | reset.v | 5 | | works | +| 0x11 | toggle.v | 0 | | works | +| 0x12 | new.room | 263 | | works | +| 0x13 | new.room.v | 14 | | works | +| 0x14 | load.logics | 135 | | works | +| 0x15 | load.logics.v | 1 | | works | +| 0x16 | call | 253 | | works | +| 0x17 | call.v | 5 | | works | +| 0x18 | load.pic | 98 | yes | no-op (draw.pic loads) | +| 0x19 | draw.pic | 102 | yes | works | +| 0x1A | show.pic | 109 | yes | works | +| 0x1B | discard.pic | 91 | yes | no-op (pictures are not kept) | +| 0x1C | overlay.pic | 9 | | works | +| 0x1D | show.pri.screen | 1 | yes | works | +| 0x1E | load.view | 375 | yes | works | +| 0x1F | load.view.v | 84 | | works | +| 0x20 | discard.view | 11 | | no-op: views go at new.room | +| 0x21 | animate.obj | 200 | yes | works | +| 0x22 | unanimate.all | 2 | | works | +| 0x23 | draw | 318 | yes | works | +| 0x24 | erase | 153 | yes | works | +| 0x25 | position | 306 | yes | works | +| 0x26 | position.v | 112 | | works | +| 0x27 | get.posn | 48 | yes | works | +| 0x28 | reposition | 12 | yes | works; FIXED (skips that cycle's step) | +| 0x29 | set.view | 477 | yes | works | +| 0x2A | set.view.v | 84 | | works | +| 0x2B | set.loop | 227 | yes | works | +| 0x2C | set.loop.v | 7 | | works | +| 0x2D | fix.loop | 44 | yes | works | +| 0x2E | release.loop | 19 | | works | +| 0x2F | set.cel | 219 | yes | works | +| 0x30 | set.cel.v | 18 | | works | +| 0x31 | last.cel | 15 | | works | +| 0x32 | current.cel | 23 | yes | works | +| 0x33 | current.loop | 11 | yes | works | +| 0x34 | current.view | 8 | yes | works | +| 0x35 | number.of.loops | 0 | | works | +| 0x36 | set.priority | 246 | yes | works | +| 0x37 | set.priority.v | 11 | | works | +| 0x38 | release.priority | 59 | | works | +| 0x39 | get.priority | 10 | | works | +| 0x3A | stop.update | 83 | yes | works | +| 0x3B | start.update | 72 | | works | +| 0x3C | force.update | 19 | | works | +| 0x3D | ignore.horizon | 59 | | works | +| 0x3E | observe.horizon | 5 | | works | +| 0x3F | set.horizon | 57 | | works | +| 0x40 | object.on.water | 6 | yes | works | +| 0x41 | object.on.land | 2 | yes | works | +| 0x42 | object.on.anything | 1 | | works | +| 0x43 | ignore.objs | 126 | yes | works | +| 0x44 | observe.objs | 15 | | works | +| 0x45 | distance | 23 | | works | +| 0x46 | stop.cycling | 150 | yes | works | +| 0x47 | start.cycling | 100 | yes | works | +| 0x48 | normal.cycle | 1 | | works | +| 0x49 | end.of.loop | 100 | yes | works | +| 0x4A | reverse.cycle | 0 | | works | +| 0x4B | reverse.loop | 23 | yes | works | +| 0x4C | cycle.time | 139 | yes | works | +| 0x4D | stop.motion | 95 | yes | works | +| 0x4E | start.motion | 72 | yes | works | +| 0x4F | step.size | 19 | yes | works | +| 0x50 | step.time | 8 | yes | works | +| 0x51 | move.obj | 155 | yes | works | +| 0x52 | move.obj.v | 59 | | works | +| 0x53 | follow.ego | 30 | yes | works | +| 0x54 | wander | 39 | | works | +| 0x55 | normal.motion | 35 | | works | +| 0x56 | set.dir | 6 | yes | works; FIXED (no effect on ego: v6 steers it) | +| 0x57 | get.dir | 4 | yes | works | +| 0x58 | ignore.blocks | 127 | yes | works | +| 0x59 | observe.blocks | 52 | | works | +| 0x5A | block | 10 | | works | +| 0x5B | unblock | 6 | | works | +| 0x5C | get | 155 | yes | works | +| 0x5D | get.v | 2 | | works | +| 0x5E | drop | 80 | | works | +| 0x5F | put | 30 | | works | +| 0x60 | put.v | 10 | | works | +| 0x61 | get.room.v | 12 | | works | +| 0x62 | load.sound | 111 | yes | no-op (sound loads) | +| 0x63 | sound | 125 | yes | works; FIXED (flag when the first voice ends) | +| 0x64 | stop.sound | 19 | yes | works; flag set | +| 0x65 | print | 1695 | yes | works | +| 0x66 | print.v | 27 | | works | +| 0x67 | display | 70 | yes | works | +| 0x68 | display.v | 5 | | works | +| 0x69 | clear.lines | 17 | yes | works; FIXED (text-screen colour is an attribute byte) | +| 0x6A | text.screen | 3 | yes | works; FIXED (clears to the attribute background) | +| 0x6B | graphics | 3 | yes | works | +| 0x6C | set.cursor.char | 1 | yes | works (no cursor before it: FIXED; changed mid-line moves the echo as Sierra's: FIXED) | +| 0x6D | set.text.attribute | 31 | yes | works; FIXED (picture-mode background means white) | +| 0x6E | shake.screen | 8 | yes | works; FIXED (measured: 4 beats per unit, (8, 4) for 4 frames and back for 4) | +| 0x6F | configure.screen | 1 | | works | +| 0x70 | status.line.on | 6 | yes | works | +| 0x71 | status.line.off | 6 | | works | +| 0x72 | set.string | 51 | | works | +| 0x73 | get.string | 56 | | works | +| 0x74 | word.to.string | 0 | | works | +| 0x75 | parse | 0 | | works | +| 0x76 | get.num | 13 | | works | +| 0x77 | prevent.input | 75 | | works; clears the input line | +| 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) | +| 0x7F | init.disk | 0 | | no-op (nothing to do on these machines) | +| 0x80 | restart.game | 1 | | works; FIXED (keeps menus and set.key) | +| 0x81 | show.obj | 61 | yes | works | +| 0x82 | random | 200 | | works | +| 0x83 | program.control | 80 | yes | works | +| 0x84 | player.control | 44 | yes | works | +| 0x85 | obj.status.v | 2 | | works: a debug window | +| 0x86 | quit | 3 | | works | +| 0x87 | show.mem | 1 | | works: a JoeyAGI message | +| 0x88 | pause | 1 | | works: a message window | +| 0x89 | echo.line | 1 | | works | +| 0x8A | cancel.line | 1 | | works | +| 0x8B | init.joy | 1 | | no-op (nothing to do on these machines) | +| 0x8C | toggle.monitor | 1 | | no-op (nothing to do on these machines) | +| 0x8D | version | 2 | | works: a JoeyAGI message | +| 0x8E | script.size | 1 | | no-op (nothing to do on these machines) | +| 0x8F | set.game.id | 1 | | no-op (nothing to do on these machines) | +| 0x90 | log | 8 | | no-op (nothing to do on these machines) | +| 0x91 | set.scan.start | 22 | | works | +| 0x92 | reset.scan.start | 18 | | works | +| 0x93 | reposition.to | 49 | yes | works; FIXED (skips that cycle's step) | +| 0x94 | reposition.to.v | 58 | | works; FIXED (as reposition.to) | +| 0x95 | trace.on | 0 | | no-op (nothing to do on these machines) | +| 0x96 | trace.info | 1 | | no-op (nothing to do on these machines) | +| 0x97 | print.at | 31 | yes | works; FIXED (columns 0 and 1 drawn and erased as Sierra's byte arithmetic does) | +| 0x98 | print.at.v | 2 | | works (as print.at) | +| 0x99 | discard.view.v | 0 | | no-op: views go at new.room | +| 0x9A | clear.text.rect | 0 | | works; FIXED (as clear.lines) | +| 0x9B | set.upper.left | 0 | | no-op (nothing to do on these machines) | +| 0x9C | set.menu | 5 | yes | works | +| 0x9D | set.menu.item | 21 | yes | works | +| 0x9E | submit.menu | 1 | yes | works | +| 0x9F | enable.item | 1 | | works | +| 0xA0 | disable.item | 3 | yes | works | +| 0xA1 | menu.input | 1 | yes | works | +| 0xA2 | show.obj.v | 1 | | works | +| 0xA3 | open.dialogue | 16 | | no-op (nothing to do on these machines) | +| 0xA4 | close.dialogue | 7 | | no-op (nothing to do on these machines) | +| 0xA5 | mul.n | 0 | | works | +| 0xA6 | mul.v | 0 | | works | +| 0xA7 | div.n | 0 | | works | +| 0xA8 | div.v | 0 | | works | +| 0xA9 | close.window | 0 | | works | + +| # | Test | KQ3 uses | Probe | Status | +|---|------|---------:|:-----:|--------| +| 0x01 | equaln | 2281 | yes | works | +| 0x02 | equalv | 35 | | works | +| 0x03 | lessn | 157 | yes | works | +| 0x04 | lessv | 12 | | works | +| 0x05 | greatern | 449 | | works | +| 0x06 | greaterv | 10 | | works | +| 0x07 | isset | 1631 | yes | works | +| 0x08 | isset.v | 1 | | works | +| 0x09 | has | 362 | | works | +| 0x0A | obj.in.room | 144 | | works | +| 0x0B | posn | 307 | | works | +| 0x0C | controller | 39 | yes | works | +| 0x0D | have.key | 4 | yes | works | +| 0x0E | said | 2991 | yes | works; FIXED (an unknown word only matches any-word and rest-of-line; all-ignored lines say nothing) | +| 0x0F | compare.strings | 28 | | works | +| 0x10 | obj.in.box | 5 | | works | +| 0x11 | center.posn | 0 | | works | +| 0x12 | right.posn | 0 | | works | + +## Gates + +`make check` (build every port, then `scripts/verify.sh`): + +| 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) | +| 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. + +## Screenshots + +`screenshots/` (git-ignored: they show Sierra's game) holds Sierra's frame and +JoeyAGI's side by side for the probes (`probe-*.png`) and the KQ3 checkpoints +(`kq3-*.png`), and the ports running KQ3 (`port-*.png`). diff --git a/agi.c b/agi.c index 13e2f8b..1681a2d 100644 --- a/agi.c +++ b/agi.c @@ -1,2086 +1,386 @@ -// JoeyLib AGI interpreter, Phase 2 host. The VM in agiVm.c owns all -// game state (vars, flags, objects, text, menus); the host renders -// the VM's snapshot every frame and bridges VM callbacks to the -// platform (resource I/O, picture decoding, text rendering, key -// dispatch). One VM cycle runs every VM_FRAMES_PER_CYCLE display -// frames; per-real-second the host pumps agiVmTickSeconds so the -// engine clock advances and per-second logic.0 branches fire. +// JoeyAGI's start-up and the interpreter cycle (AGI specification 3.1). // -// Argv layout: -// argv[1] = game directory (defaults to ".") -// argv[2] = starting room (optional; 1..255 overrides logic.0's -// hardcoded new.room target so you can skip past a title -// screen the VM can't yet advance through. 0 means "use -// whatever logic.0 picks", same as omitting the argument.) - +// Every v10 twentieths of a second the interpreter runs one cycle: the +// controllers and f2/f4 are cleared and the queued keys handed out, the +// special motions pick their directions, logic 0 runs (and with it every +// logic it calls), the variables the interpreter reports are reset, and +// the updated objects animate and move. new.room stops all logic, +// changes room and runs logic 0 again within the same cycle. +// +// Between cycles, and while a window or menu waits for a key, agiIdle +// runs a frame: input, sound, the clock (v11..v14) and the display. #include "agi.h" +#include +#include +#include + #include "joey/audio.h" #include "joey/core.h" #include "joey/debug.h" #include "joey/draw.h" #include "joey/input.h" #include "joey/palette.h" -#include "joey/platform.h" #include "joey/present.h" -#include "joey/sprite.h" -#include "joey/surface.h" -#include "surfaceInternal.h" - -#include -#include -#include -#define AGI_LOGIC_HDR_BYTES 2u -// Border fill color (rows above/below the AGI picture region and any -// area not painted by show.pic). AGI canonical: black (palette 0). -#define COLOR_MISSING 0u -#define CYCLE_HALT_GUARD 16u -#define EGO_START_X 80 -#define EGO_START_Y 140 -#define EGO_STEP_PX 2 - -// AGI v2 EGA layout on a 320x200 display: pic occupies y=0..167 -// (160x168 doubled horizontally to 320 wide), and y=168..199 is the -// 4-text-row band below the picture. Text rows always use y = row*8, -// so pic at y=0 keeps the in-picture rows (0..20) visually aligned -// with the text rendered into them. Status line, when on, overlays -// the picture at its assigned row -- that's Sierra's canonical -// behaviour. -// Canonical AGI: 1 jiffy = 50ms, VM cycle dispatch every v10 -// jiffies. KQ3 sets v10=1 -> 20 cycles/sec; default is 2 -> -// 10 cycles/sec. We drive the cycle off wall-clock so the -// rate stays correct regardless of host frame rate. -#define AGI_JIFFY_MS 50u -// Renders are wall-clock paced too. AGI canonical runs at ~20 Hz -// (one VM cycle = one rendered frame in Sierra's interpreter); we -// render slightly above that for smoother sparkle/cel cycling but -// not the full 70 Hz VBL rate, which on emulated 386 in DOSBox -// burns enough CPU on backdrop-restore + cel redraw that audio -// refill misses its 92 ms half-buffer deadline. -#define AGI_RENDER_PERIOD_MS 33u -#define STARTUP_CYCLE_LIMIT 32u -// Per Sierra KQ3 v2 EGA reference: pic starts 8 pixels down from -// the top of the 320x200 display (one 8-pixel text-row band reserved -// for the status line). Pic occupies y=8..175. Text rows use the -// row*8 absolute Y -- no pic offset added -- so they deliberately -// overlap pic art where the script wants captions inside the picture. -#define PIC_DEST_Y 8 - -// (Per-object save-under removed: we now restore each object's prev -// rect by memcpy from the gBackdrop surface, sized to the actual -// cel bbox at draw time. That handles arbitrary cel sizes without -// any per-object backup buffers and is faster on chunky platforms.) - -// Canonical AGI ego loop indices. -#define LOOP_RIGHT 0 -#define LOOP_LEFT 1 -#define LOOP_DOWN 2 -#define LOOP_UP 3 - -// Encryption key applied per-message in the AGI v2 message section. -// Same string in every supported game; XOR cycles every 11 bytes. -#define MSG_KEY "Avis Durgan" -#define MSG_KEY_LEN 11u - -// Largest decrypted message we'll surface to the VM. Game scripts -// stay well under this (most under 80 chars); the cap protects the -// scratch buffer from a corrupt resource. -#define MSG_BUFFER_BYTES 256u +#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 +#define DEFAULT_CYCLE_DELAY 2u +#define DEFAULT_INPUT_MAX 41u +#define DEFAULT_FREE_PAGES 100u +#define DEFAULT_PIC_ROW 0u +#define DEFAULT_INPUT_ROW 23u +#define DEFAULT_STATUS_ROW 21u +#define COMPUTER_IBM_PC 0u +#define SOUND_PC 1u +#define MONITOR_EGA 3u +#define RANDOM_SEED 0xA61DA61Du +#define STATUS_TEXT_MAX 160u -// ----- Types ----- - -typedef struct { - uint8_t *raw; - uint16_t rawLength; - const uint8_t *bytecode; - uint16_t bytecodeLength; -} LogicCacheSlotT; - -typedef struct { - AgiViewT view; - bool loaded; -} ViewCacheSlotT; +// The AGI palette: EGA's sixteen colours in JoeyLib's $0RGB. +static const uint16_t kAgiPalette[AGI_COLOR_COUNT] = { + 0x000u, 0x00Au, 0x0A0u, 0x0AAu, + 0xA00u, 0xA0Au, 0xA50u, 0xAAAu, + 0x555u, 0x55Fu, 0x5F5u, 0x5FFu, + 0xF55u, 0xF5Fu, 0xFF5u, 0xFFFu +}; // ----- Prototypes ----- -static void agiSoundReset(void); -static uint32_t agiSynthFill(void *ctx, int8_t *dst, uint32_t count); -static void agiTickAdvance(void); -static void agiVoiceTick(uint8_t voice); -static void psgNoiseApply(void); -static void psgRecompute(uint8_t chipCh); -static void psgWriteData(uint8_t val); -static void cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel, - uint8_t x, uint8_t y, uint8_t pri, uint8_t margin); -static void cbDiscardPic(void *ctx, uint8_t picId); -static void cbDrawPic(void *ctx, uint8_t picId); -static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength); -static const char *cbFetchMessage(void *ctx, uint8_t logicId, uint8_t msgId); -static bool cbHaveKey(void *ctx); -static bool cbIsPlayingSound(void *ctx); -static void cbLoadPic(void *ctx, uint8_t picId); -static void cbOverlayPic(void *ctx, uint8_t picId); -static void cbPlaySound(void *ctx, uint8_t soundId); -static void cbShowPic(void *ctx); -static uint32_t cbSoundDuration(void *ctx, uint8_t soundId); -static void cbStopSound(void *ctx); -static uint8_t cbViewCelCount(void *ctx, uint8_t viewId, uint8_t loopId); -static uint8_t cbViewLoopCount(void *ctx, uint8_t viewId); -static bool decodePicInto(uint8_t picId, bool clearFirst); -static void agiInputCapture(void); -static void agiParseCommand(void); -static void dispatchAllPressedKeys(void); -static void drawObjects(jlSurfaceT *stage); -static const AgiViewT *fetchView(uint8_t viewId); -static void invalidateObjectSaves(void); -static uint8_t loopForDirection(uint8_t direction, uint8_t loopCount); -static uint8_t parseArgU8(const char *s); -static void releaseLogicCache(void); -static void releaseViewCache(void); -static void renderFrame(jlSurfaceT *stage); -static uint8_t resolveStartingRoom(int argc, char **argv); -static void runVmCycle(void); -static void updateEgoFromInput(void); +static void changeRoom(void); +static void cycle(void); +static void steerEgo(AgiObjectT *ego); +static void tickClock(void); // ----- Module state ----- -static AgiGameT gGame; -static AgiPicT gPic; -static jlSurfaceT *gBackdrop; -static jlSurfaceT *gAgiStage; -static AgiVmT gVm; -static LogicCacheSlotT gLogicCache[AGI_MAX_RESOURCES]; -// View cache: 256 slots of ~5 KB each (~1.3 MB). Far too large for the -// IIgs bank-0 BSS, so it is jlAlloc'd at startup (Memory Manager handle -// on the IIgs, malloc elsewhere) rather than declared as a static array. -static ViewCacheSlotT *gViewCache; -static bool gPicReady; -static uint8_t gOverrideRoom; -static bool gOverrideRoomActive; -static bool gAnyKeyHit; -// Per-object previously-drawn stage rect. After each draw we record -// the cel's exact stage bbox here; next frame's pass 1 restores that -// rect from gBackdrop, then pass 2 redraws. valid=false means the -// object wasn't drawn last frame (skip restore). -typedef struct { - int16_t x; - int16_t y; - int16_t w; - int16_t h; - bool valid; -} ObjPrevRectT; - -static ObjPrevRectT gObjPrev[AGI_MAX_OBJECTS]; -// Per-text-row "had content last frame" flag. We restore each row's -// stripe of the backdrop at the start of every frame if it was -// dirtied last frame, so the old text disappears when the VM clears -// it (clear.lines) or moves to a different row. -static bool gTextRowDirty[AGI_TEXT_ROWS]; -// One static scratch for the most recently decrypted message. The -// fetchMessage callback contract guarantees its return pointer is -// only valid until the next call -- the VM's expandMessage copies -// the bytes it needs before calling fetchMessage again. -static char gMessageScratch[MSG_BUFFER_BYTES]; - -// SN76489 PSG clock divisor base. AGI v2 uses the TI PSG's tone -// generator: tone_hz = 111860 / divisor (3.579545 MHz / 32). -#define PSG_CLOCK_HZ 111860u - -// AGI sound is software-synthesized at 22050 Hz mono then pushed -// through the existing Sound Blaster auto-init DMA path via the -// jlAudioPlaySfxStream API. Three independent squarewave voices -// (16-bit phase, 8-bit amplitude) sum into the output stream; AGI -// tick logic advances every 22050/60 = 367 output samples, matching -// ScummVM's PCjr/Tandy sound model (which is also how Sierra's own -// SB drivers shipped the AGI sound after 1988). -// -// This is the "software synth to PCM" model. Pros: works on any -// SB-equipped DOS box, no hardware-IRQ headaches, sample-accurate -// note timing because we're rendering audio at 22050 Hz with the -// AGI ticks embedded. Cons: requires SB-compat audio (true for -// every target since 1989). The PCjr/Tandy native PSG path (cheaper, -// older) is a separate problem we can revisit later. -// -// SND v2 layout (per channel): -// bytes 0..7 : 4 channel offsets (LE16 each) -// per channel : sequence of 5-byte events. Bytes 0..1 = duration -// LE16 in 1/60s units, terminator 0xFFFF; bytes 2/3 -// = raw SN76489 latch+data pair encoding the 10-bit -// frequency divisor (low 4 bits in byte 3, high 6 -// bits in byte 2); byte 4 = 4-bit attenuation -// (0 = loudest, 15 = silent). -// freq_hz = 111860 / divisor (PSG clock 3.579545 MHz / 32). -// -// Sample rate must match the SB DMA setup (see MIX_RATE in -// src/port/dos/audio.c). 22050 keeps 256-sample stream chunks at -// ~12 ms each, well over an AGI tick. -typedef struct { - uint16_t chOffset; // cursor in this channel's event stream - uint16_t eventFramesRem; // ticks until current event ends - bool done; // channel hit 0xFFFF terminator -} AgiVoiceSchedT; - -// Per-SN76489-channel state. The chip has 3 tone channels + 1 noise -// channel. Each event in the SND stream is 3 raw chip-command bytes -// (b4=atten, b3=latch-or-noise-control, b2=data) that may target -// any chip channel, not necessarily the AGI channel that owns the -// stream. We dispatch the bytes through psgWriteData (mirrors -// ScummVM's writeData) and let the chip-state machine update the -// right register. The synth then reads each tone channel's derived -// (amplitude, phaseInc) and produces samples. -typedef enum { - PSG_GEN_TONE = 0, - PSG_GEN_NOISE_PERIOD = 1, - PSG_GEN_NOISE_WHITE = 2 -} PsgGenE; - -typedef struct { - uint16_t freqCount; // 10-bit tone period; noise uses small values per ScummVM - uint8_t attenuation; // 0=loud, 15=silent - uint8_t genType; // PsgGenE - // Derived cache (recomputed when freqCount/attenuation change): - uint16_t phaseInc; - int8_t amplitude; - // Synth state: - uint16_t phase; -} PsgChannelT; - -// AGI v2 SND has 4 sub-channels: 3 tone + 1 noise. The "AGI -// channel" indexes into the event-stream offsets table at the -// start of the file; it does NOT correspond 1:1 to the chip's 4 -// channels. Each AGI channel ticks an event stream and fires raw -// chip writes; the chip state is shared across all AGI channels. -#define AGI_SOUND_CHANNELS 4u -#define AGI_SOUND_TONE_VOICES 3u -#define AGI_SOUND_NOISE_CHANNEL 3u -#define AGI_SOUND_END_MARKER 0xFFFFu -// AGI sound tick rate. Sierra's PCjr/Tandy SOUND.DRV ran at 120 Hz -// (vs ScummVM's 60 Hz). All script-driven timing (v11 seconds -// counter, animation cycling, motion stepping) is derived from -// this same tick rate, NOT from wall clock -- so doubling the -// tick rate makes both the music AND v11 advance 2x faster, which -// keeps script-internal wait conditions consistent. -#define AGI_SOUND_TICKS_HZ 120u -#define AGI_SYNTH_SAMPLE_RATE 22050u -#define AGI_SYNTH_SAMPLES_PER_TICK (AGI_SYNTH_SAMPLE_RATE / AGI_SOUND_TICKS_HZ) - -// SN76489 attenuation -> int8 amplitude lookup. 2 dB per step -// (so atten=1 is 0.794x atten=0, atten=2 is 0.794x atten=1, etc.). -// Linear (15-atten)/15 mapping makes mid-volume notes far too loud -// and turns fade-outs into harsh cliffs; this table matches -// ScummVM's int16 PSG table scaled down to our 40-peak headroom -// for safe 3-voice sums into int8 PCM. -static const int8_t kAgiAttenAmp[16] = { - 40, 32, 25, 20, 16, 13, 10, 8, - 6, 5, 4, 3, 3, 2, 2, 0 -}; - -// All synth + scheduler state runs entirely on the main thread now -// (the stream fill callback is invoked from halAudioFrameTick, not -// from any IRQ). No critical sections needed. -static uint8_t *gSoundBytes; -static uint16_t gSoundLength; -static AgiVoiceSchedT gVoiceSched[AGI_SOUND_CHANNELS]; -static PsgChannelT gPsg[4]; // 3 tone + 1 noise -static uint8_t gPsgReg; // last register selected by a LATCH or ATTEN byte -static bool gSoundActive; -// AGI ticks are driven from wall-clock ms, NOT from samples -// produced. The previous countdown-per-sample approach assumed -// the synth's call rate matched SB DMA's drain rate (22050/sec). -// In practice DOSBox's SB Pro emulation at cycles=2200 drains -// the DMA buffer roughly 2.1x faster than the requested time -// constant (or fires refill IRQs at half the block-size cadence, -// or some combination -- empirically observed 47-50 kHz -// production rate when we asked for 22050). Pitch is unaffected -// because phaseInc still matches the actual DMA rate, but tempo -// (ticks/sec) was running 2.1x fast. Wall-clock tick gating -// locks tempo to 60 Hz exactly regardless of DMA quirks. -#define AGI_TICK_PERIOD_US (1000000u / AGI_SOUND_TICKS_HZ) // 8333 us at 120 Hz -// Tick counter advanced by agiTickAdvance() from the host loop. -// All AGI script-driven timing keys off this, NOT off wall clock, -// so changing AGI_SOUND_TICKS_HZ uniformly speeds up music + v11 -// + animation + script waits together. v11 advances every 60 AGI -// ticks (= 1 "AGI sec"); at AGI_SOUND_TICKS_HZ=120 that's twice -// per wall second, matching Sierra's PCjr/Tandy game-tick model. -static uint32_t gAgiTickAccumUs; -static uint32_t gAgiTickLastWallMs; -static uint32_t gAgiV11SubTickAccum; // counts AGI ticks toward next v11++ +AgiStateT *gAgi; +static uint32_t gClockMs; +static uint32_t gClockLastMs; // ----- Internal helpers (alphabetical) ----- -static void cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel, - uint8_t x, uint8_t y, uint8_t pri, uint8_t margin) { - const AgiViewT *view; +// new.room's work (specification 3.1 and 4.2): everything off the +// screen, the logics forgotten, the player in control, ego moved to the +// edge it left by, and the new room's logic loaded. +static void changeRoom(void) { + AgiObjectT *ego; - (void)ctx; - view = fetchView(viewId); - if (view == NULL) { - return; + gAgi->newRoomPending = false; + objUnanimateAll(); + vmDiscardLogics(); + agiViewDiscardUnused(); + saveScriptClear(); + gAgi->playerControl = true; + gAgi->blockActive = false; + gAgi->horizon = AGI_DEFAULT_HORIZON; + gAgi->vars[VAR_PREV_ROOM] = gAgi->vars[VAR_ROOM]; + gAgi->vars[VAR_ROOM] = gAgi->newRoomNumber; + gAgi->vars[VAR_OBJ_EDGE_OBJ] = 0u; + gAgi->vars[VAR_OBJ_EDGE] = 0u; + gAgi->vars[VAR_BAD_WORD] = 0u; + ego = &gAgi->objects[AGI_EGO]; + gAgi->vars[VAR_EGO_VIEW] = ego->viewId; + ego->direction = AGI_DIR_NONE; + ego->motion = AGI_MOTION_NORMAL; + gAgi->vars[VAR_EGO_DIR] = AGI_DIR_NONE; + switch (gAgi->vars[VAR_EGO_EDGE]) { + case AGI_EDGE_TOP: + ego->y = AGI_MAX_Y; + break; + case AGI_EDGE_RIGHT: + ego->x = 0; + break; + case AGI_EDGE_BOTTOM: + ego->y = (int16_t)(AGI_DEFAULT_HORIZON + 1u); + break; + case AGI_EDGE_LEFT: + ego->x = (int16_t)(AGI_WIDTH - (int16_t)ego->width); + break; + default: + break; } - // Mutate the in-memory pic buffer only. The companion show.pic - // call (which always follows an add.to.pic batch in canonical - // AGI logic) is what blits the updated buffer to the stage and - // republishes the backdrop. Per-add.to.pic blits would compound - // into 100s of ms of redundant pixel work on slow targets. - agiPicAddView(&gPic, view, loop, cel, (int16_t)x, (int16_t)y, pri, margin); + gAgi->vars[VAR_EGO_EDGE] = AGI_EDGE_NONE; + (void)vmLoadLogic(gAgi->vars[VAR_ROOM]); + gAgi->logicLoaded[gAgi->vars[VAR_ROOM]] = true; + gAgi->flags[FLAG_ENTERED] = 0u; + gAgi->flags[FLAG_NEW_ROOM] = 1u; + inputReset(); } -// Accumulate typed characters into gVm.inputLine while the command line is -// active (accept.input). Printable keys append, backspace deletes, and Enter -// hands the finished line to agiParseCommand. Called every frame from the -// main loop; the on-screen echo is drawn in renderFrame. -static void agiInputCapture(void) { - uint8_t k; +static void cycle(void) { + AgiObjectT *ego; + uint8_t chosen; - if (jlKeyPressed(KEY_BACKSPACE)) { - if (gVm.inputLen > 0u) { - gVm.inputLen--; - gVm.inputLine[gVm.inputLen] = '\0'; + memset(gAgi->controllers, 0, sizeof(gAgi->controllers)); + gAgi->flags[FLAG_ENTERED] = 0u; + gAgi->flags[FLAG_SAID_OK] = 0u; + gAgi->vars[VAR_BAD_WORD] = 0u; + if (gAgi->menuRequested) { + gAgi->menuRequested = false; + chosen = textMenuInput(); + if (chosen != AGI_NO_CONTROLLER) { + gAgi->controllers[chosen] = 1u; } } - if (jlKeyPressed(KEY_SPACE) && gVm.inputLen < AGI_INPUT_MAX) { - gVm.inputLine[gVm.inputLen] = ' '; - gVm.inputLen++; - gVm.inputLine[gVm.inputLen] = '\0'; - } - for (k = 0u; k < 26u; k++) { - if (jlKeyPressed((jlKeyE)((uint8_t)KEY_A + k)) && gVm.inputLen < AGI_INPUT_MAX) { - gVm.inputLine[gVm.inputLen] = (char)('a' + k); - gVm.inputLen++; - gVm.inputLine[gVm.inputLen] = '\0'; - } - } - for (k = 0u; k < 10u; k++) { - if (jlKeyPressed((jlKeyE)((uint8_t)KEY_0 + k)) && gVm.inputLen < AGI_INPUT_MAX) { - gVm.inputLine[gVm.inputLen] = (char)('0' + k); - gVm.inputLen++; - gVm.inputLine[gVm.inputLen] = '\0'; - } - } - if (jlKeyPressed(KEY_RETURN)) { - if (gVm.inputLen > 0u) { - agiParseCommand(); - } - gVm.inputLine[0] = '\0'; - gVm.inputLen = 0u; - } -} - - -// Tokenize gVm.inputLine into dictionary group IDs (gVm.parsedWords), using -// greedy longest-match so multi-word entries ("crystal ball", "get in") beat -// their prefixes. Ignore-words (group 0) drop out; a word absent from the -// dictionary records its 1-based position in var 9 and voids the command -// (no parsedWords, so said() won't match and the room can report the miss). -// Sets flag 2 so said() tests fire this cycle. -static void agiParseCommand(void) { - static char toks[AGI_MAX_PARSED][AGI_WORD_MAX_LEN + 1u]; - static uint8_t tokLen[AGI_MAX_PARSED]; - char phrase[AGI_WORD_MAX_LEN + 1u]; - uint8_t tokCount; - uint8_t ti; - uint16_t i; - uint8_t pos; - bool unknown; - - // Split into lowercase alphanumeric tokens (cap at AGI_MAX_PARSED). - tokCount = 0u; - ti = 0u; - for (i = 0u; i <= gVm.inputLen; i++) { - char c; - - c = (i < gVm.inputLen) ? gVm.inputLine[i] : ' '; - if (c >= 'A' && c <= 'Z') { - c = (char)(c + ('a' - 'A')); - } - if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { - if (tokCount < AGI_MAX_PARSED && ti < AGI_WORD_MAX_LEN) { - toks[tokCount][ti] = c; - ti++; - } - continue; - } - if (ti > 0u && tokCount < AGI_MAX_PARSED) { - toks[tokCount][ti] = '\0'; - tokLen[tokCount] = ti; - tokCount++; - } - ti = 0u; - } - - gVm.parsedWordCount = 0u; - gVm.vars[AGI_VAR_UNKNOWN_WORD] = 0u; - unknown = false; - pos = 0u; - while (pos < tokCount) { - uint16_t bestId; - uint8_t best; - uint8_t span; - - best = 0u; - bestId = AGI_WORD_UNKNOWN; - // Longest join first: try tokens[pos..end], then shorter, down to one. - for (span = (uint8_t)(tokCount - pos); span >= 1u; span--) { - uint8_t pl; - uint8_t s; - uint16_t id; - - pl = 0u; - for (s = 0u; s < span; s++) { - uint8_t j; - - if (s > 0u && pl < AGI_WORD_MAX_LEN) { - phrase[pl] = ' '; - pl++; - } - for (j = 0u; j < tokLen[pos + s] && pl < AGI_WORD_MAX_LEN; j++) { - phrase[pl] = toks[pos + s][j]; - pl++; - } - } - phrase[pl] = '\0'; - id = agiWordId(&gGame, phrase); - if (id != AGI_WORD_UNKNOWN) { - best = span; - bestId = id; - break; - } - } - if (best == 0u) { - if (!unknown) { - gVm.vars[AGI_VAR_UNKNOWN_WORD] = (uint8_t)(pos + 1u); - unknown = true; - } - pos++; - continue; - } - if (bestId != AGI_WORD_IGNORE && gVm.parsedWordCount < AGI_MAX_PARSED) { - gVm.parsedWords[gVm.parsedWordCount] = bestId; - gVm.parsedWordCount++; - } - pos = (uint8_t)(pos + best); - } - - if (unknown) { - gVm.parsedWordCount = 0u; - } - gVm.flags[AGI_FLAG_INPUT] = 1u; -} - - -static void cbDiscardPic(void *ctx, uint8_t picId) { - (void)ctx; - (void)picId; -} - - -static void cbDrawPic(void *ctx, uint8_t picId) { - uint8_t i; - - (void)ctx; - jlLogF("cbDrawPic id=%u v11=%u", (unsigned)picId, (unsigned)gVm.vars[11]); - // Dump active objects so we can see whether scene leftovers - // (e.g. sparkle objects from previous scene) are still flagged - // animated/drawn at scene-change time. - for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { - const AgiObjectT *o = &gVm.objects[i]; - if (o->flags != 0u) { - jlLogF(" obj[%u] flags=0x%02x view=%u loop=%u cel=%u x=%d y=%d", - (unsigned)i, (unsigned)o->flags, - (unsigned)o->viewId, (unsigned)o->loop, (unsigned)o->cel, - (int)o->x, (int)o->y); - } - } - // Dump set flags so we can compare flag state across scene - // transitions and identify which flag gates a wrong-path branch. - { - uint16_t fi; - char line[128]; - size_t used; - line[0] = '\0'; - used = 0u; - for (fi = 0u; fi < AGI_VM_NUM_FLAGS; fi++) { - if (gVm.flags[fi] != 0u) { - int n = snprintf(line + used, sizeof(line) - used, "%u ", (unsigned)fi); - if (n < 0 || (size_t)n >= sizeof(line) - used) { - jlLogF(" flagsSet=%s...(trunc)", line); - line[0] = '\0'; - used = 0u; - } else { - used += (size_t)n; - } - } - } - jlLogF(" flagsSet=%s", line); - } - jlLogFlush(); - decodePicInto(picId, true); -} - - -static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength) { - LogicCacheSlotT *slot; - uint8_t *raw; - uint16_t rawLength; - uint16_t bcLen; - - (void)ctx; - slot = &gLogicCache[logicId]; - if (slot->bytecode != NULL) { - *outLength = slot->bytecodeLength; - return slot->bytecode; - } - if (logicId >= gGame.resCount[AGI_RES_LOGIC]) { - return NULL; - } - raw = agiResLoad(&gGame, AGI_RES_LOGIC, (uint16_t)logicId, &rawLength); - if (raw == NULL || rawLength < AGI_LOGIC_HDR_BYTES) { - if (raw != NULL) { - free(raw); - } - return NULL; - } - bcLen = (uint16_t)(raw[0] | ((uint16_t)raw[1] << 8)); - if ((uint16_t)(AGI_LOGIC_HDR_BYTES + bcLen) > rawLength) { - free(raw); - return NULL; - } - slot->raw = raw; - slot->rawLength = rawLength; - slot->bytecode = raw + AGI_LOGIC_HDR_BYTES; - slot->bytecodeLength = bcLen; - *outLength = bcLen; - return slot->bytecode; -} - - -// Look up message `msgId` in logic `logicId`'s message table, decrypt -// it via the cycling "Avis Durgan" XOR, write the result to the -// shared scratch buffer, and return a pointer to it. Returns NULL -// on missing message; returns "" for an empty slot. -// -// AGI v2 quirks the spec requires: -// 1. The 2-byte offsets stored in the table are relative to byte 1 -// of the message section (i.e. they exclude the count byte), so -// the actual byte position is stored_offset + 1. -// 2. The XOR key cycles continuously across the whole strings -// block, not per-message. Key index for byte K = (K - strings -// start byte) mod 11. -static const char *cbFetchMessage(void *ctx, uint8_t logicId, uint8_t msgId) { - LogicCacheSlotT *slot; - const uint8_t *raw; - const uint8_t *msgSection; - uint16_t msgSectionOff; - uint16_t rawLength; - uint16_t msgCount; - uint16_t msgOff; - uint16_t stringsStartOff; - uint16_t k; - uint16_t i; - - (void)ctx; - slot = &gLogicCache[logicId]; - if (slot->raw == NULL) { - uint16_t ignore = 0u; - if (cbFetchLogic(NULL, logicId, &ignore) == NULL) { - return NULL; - } - } - raw = slot->raw; - rawLength = slot->rawLength; - msgSectionOff = (uint16_t)(AGI_LOGIC_HDR_BYTES + slot->bytecodeLength); - if (msgSectionOff >= rawLength) { - return NULL; - } - msgSection = raw + msgSectionOff; - msgCount = (uint16_t)msgSection[0]; - if (msgId == 0u || msgId > msgCount) { - return NULL; - } - // Find the lowest non-zero stored offset across all message - // slots. That's where the encrypted strings block begins. - stringsStartOff = 0xFFFFu; - for (k = 1u; k <= msgCount; k++) { - uint16_t off; - uint16_t tOff = (uint16_t)(3u + (uint16_t)((k - 1u) * 2u)); - if ((uint16_t)(msgSectionOff + tOff + 1u) >= rawLength) { - continue; - } - off = (uint16_t)(msgSection[tOff] | ((uint16_t)msgSection[tOff + 1u] << 8)); - if (off != 0u && off < stringsStartOff) { - stringsStartOff = off; - } - } - if (stringsStartOff == 0xFFFFu) { - gMessageScratch[0] = '\0'; - return gMessageScratch; - } - { - uint16_t tableOff; - uint16_t msgStart; - uint16_t keyBase; - - tableOff = (uint16_t)(3u + (uint16_t)((msgId - 1u) * 2u)); - if ((uint16_t)(msgSectionOff + tableOff + 1u) >= rawLength) { - return NULL; - } - msgOff = (uint16_t)(msgSection[tableOff] | ((uint16_t)msgSection[tableOff + 1u] << 8)); - if (msgOff == 0u) { - gMessageScratch[0] = '\0'; - return gMessageScratch; - } - // actual byte = section_off + stored_offset + 1. - msgStart = (uint16_t)(msgSectionOff + msgOff + 1u); - if (msgStart >= rawLength) { - return NULL; - } - // continuous key index = (msgOff - stringsStartOff) at byte 0 - // of this message, then increments per byte. - keyBase = (uint16_t)(msgOff - stringsStartOff); - i = 0u; - while (msgStart + i < rawLength && i < (uint16_t)(MSG_BUFFER_BYTES - 1u)) { - uint8_t enc; - uint8_t dec; - - enc = raw[msgStart + i]; - dec = (uint8_t)(enc ^ (uint8_t)MSG_KEY[(keyBase + i) % MSG_KEY_LEN]); - if (dec == 0u) { - break; - } - gMessageScratch[i] = (char)dec; - i++; - } - gMessageScratch[i] = '\0'; - } - return gMessageScratch; -} - - -static bool cbHaveKey(void *ctx) { - bool hit; - - (void)ctx; - hit = gAnyKeyHit; - gAnyKeyHit = false; - return hit; -} - - -static void cbLoadPic(void *ctx, uint8_t picId) { - (void)ctx; - (void)picId; -} - - -static void cbOverlayPic(void *ctx, uint8_t picId) { - (void)ctx; - jlLogF("cbOverlayPic id=%u v11=%u", (unsigned)picId, (unsigned)gVm.vars[11]); - decodePicInto(picId, false); -} - - -static void cbShowPic(void *ctx) { - (void)ctx; - jlLogF("cbShowPic v11=%u", (unsigned)gVm.vars[11]); - if (gAgiStage == NULL) { - return; - } - // Clear the WHOLE stage first so the rows above/below the AGI - // picture region (y=0..15 and y=184..199) don't retain pixels - // from the previous scene. Without this, text drawn during the - // logo phase at rows 22..24 ends up captured by the backdrop - // copy below and gets restored over every subsequent frame -- - // so clear.lines never visually erases anything that sat in - // the border bands. - jlSurfaceClear(gAgiStage, COLOR_MISSING); - agiPicBlit(&gPic, gAgiStage, PIC_DEST_Y); - if (gBackdrop != NULL) { - jlSurfaceCopy(gBackdrop, gAgiStage); - } - gPicReady = true; - // The stage just got repainted; any prior save-under refers to - // pre-paint pixels and must NOT be restored next frame (it would - // leak the old picture over the new one). - invalidateObjectSaves(); -} - - -// Push the chip's noise-channel state (gPsg[3]) to the JoeyLib noise -// voice. The SN76489 freqCount maps onto the portable 0..31 pitch as -// freqCount >> 3 (rate codes 0/1/2 -> 4/8/16, preserving the chip's -// octave-per-step; mode 3 tracks tone channel 2's snapshot). The -// white/periodic bit has no portable representation and renders as -// plain hiss. -static void psgNoiseApply(void) { - uint16_t count; - uint8_t pitch; - - if (gPsg[3].attenuation >= 15u || gPsg[3].freqCount == 0u) { - jlAudioNoise(0u, 15u); - return; - } - count = gPsg[3].freqCount; - pitch = (uint8_t)(count >> 3); - if (pitch > 31u) { - pitch = 31u; - } - jlAudioNoise(pitch, gPsg[3].attenuation); -} - - -// Recompute derived (phaseInc, amplitude) for chip channel -// `chipCh` from its current freqCount + attenuation + genType. -// Called whenever the chip state for a tone channel changes; the -// noise channel isn't synthesized into the PCM stream -- it goes to -// the hardware noise voice instead. -static void psgRecompute(uint8_t chipCh) { - PsgChannelT *p; - uint32_t freqHz; - - if (chipCh >= 3u) { - psgNoiseApply(); - return; - } - p = &gPsg[chipCh]; - if (p->genType != PSG_GEN_TONE || p->freqCount == 0u || p->attenuation >= 15u) { - p->phaseInc = 0u; - p->amplitude = 0; - return; - } - freqHz = (uint32_t)PSG_CLOCK_HZ / (uint32_t)p->freqCount; - p->phaseInc = (uint16_t)((freqHz * 65536u) / AGI_SYNTH_SAMPLE_RATE); - p->amplitude = kAgiAttenAmp[p->attenuation]; -} - - -// SN76489 byte dispatcher, byte-for-byte equivalent to ScummVM's -// SoundGenPCJr::writeData. The chip has 4 channels (3 tone + 1 -// noise) and a single "last register" latch used by DATA bytes -// to know which channel to update. -// -// Byte forms (per the SN76489 datasheet): -// 1rrr1xxx (atten): bits 6-5 = chip channel, bits 3-0 = atten. -// Updates _reg and the channel's attenuation. -// 11100xxx (noise): noise channel (3); bit 2 = white/period, -// bits 1-0 = period code. _reg NOT updated -- -// the DATA byte that follows still targets -// whatever channel was last selected by a tone -// latch or atten command. -// 1rrr0xxx (tone latch): bits 6-5 = chip channel, bits 3-0 = -// LOW 4 bits of freqCount. Sets _reg, fully -// replaces low nibble, marks gen as tone. -// 0xxxxxxx (data): ORs (val & 0x3F) << 4 into freqCount of -// whichever channel _reg points to (the high -// 6 bits of the 10-bit divisor). The OR is -// key: an event without a fresh tone latch -// keeps its previous low nibble, so two -// events can share a low-nibble setup and a -// noise-control byte in between leaves the -// tone channel's freqCount mostly intact. -static void psgWriteData(uint8_t val) { - uint8_t reg; - - if ((val & 0x90u) == 0x90u) { - gPsgReg = (uint8_t)((val >> 5) & 0x03u); - gPsg[gPsgReg].attenuation = (uint8_t)(val & 0x0Fu); - psgRecompute(gPsgReg); - } else if ((val & 0xF0u) == 0xE0u) { - gPsg[3].genType = (val & 0x04u) ? PSG_GEN_NOISE_WHITE : PSG_GEN_NOISE_PERIOD; - switch (val & 0x03u) { - case 0u: gPsg[3].freqCount = 32u; break; - case 1u: gPsg[3].freqCount = 64u; break; - case 2u: gPsg[3].freqCount = 128u; break; - default: gPsg[3].freqCount = (uint16_t)(gPsg[2].freqCount * 2u); break; - } - // No _reg update -- the DATA byte that follows still targets - // the previously latched channel. - psgNoiseApply(); - } else if (val & 0x80u) { - reg = (uint8_t)((val >> 5) & 0x03u); - gPsgReg = reg; - gPsg[reg].freqCount = (uint16_t)(val & 0x0Fu); - gPsg[reg].genType = PSG_GEN_TONE; - psgRecompute(reg); - } else { - gPsg[gPsgReg].freqCount |= (uint16_t)((val & 0x3Fu) << 4); - psgRecompute(gPsgReg); - } -} - - -// Silence every channel and free the owned SND buffer. Called from -// main thread; stops the SFX-stream slot first so the synth -// callback can't be invoked again against a freed buffer. -static void agiSoundReset(void) { - uint8_t v; - - jlAudioStopSfx(0u); - // The noise voice is live hardware, separate from the PCM stream - // slot -- without an explicit off it latches on forever. - jlAudioNoise(0u, 15u); - for (v = 0u; v < AGI_SOUND_CHANNELS; v++) { - gVoiceSched[v].chOffset = 0u; - gVoiceSched[v].eventFramesRem = 0u; - gVoiceSched[v].done = true; - } - for (v = 0u; v < 4u; v++) { - gPsg[v].freqCount = 0u; - gPsg[v].attenuation = 15u; // silent - gPsg[v].genType = PSG_GEN_TONE; - gPsg[v].phaseInc = 0u; - gPsg[v].amplitude = 0; - gPsg[v].phase = 0u; - } - gPsgReg = 0u; - gSoundActive = false; - gAgiTickAccumUs = 0u; - gAgiTickLastWallMs = 0u; - gAgiV11SubTickAccum = 0u; - if (gSoundBytes != NULL) { - free(gSoundBytes); - gSoundBytes = NULL; - } - gSoundLength = 0u; -} - - -// Advance one AGI channel by one tick. When the channel's -// duration counter rolls over, parse the 5-byte event header -// (dur LE16 + 3 chip-command bytes) and feed the 3 chip bytes -// through psgWriteData in ScummVM's order: byte 4 (atten), byte -// 3 (latch or noise control), byte 2 (data). The chip state is -// shared across all AGI channels, so an event in AGI channel N -// can update chip-channel state for any chip register, including -// the noise channel and other tone channels' attenuation. -// -// dur=0 events fire-then-skip in the same tick (AGI uses them for -// legato note transitions). Inner-loop guard caps iterations so -// a malformed channel can't lock the synth. -static void agiVoiceTick(uint8_t v) { - AgiVoiceSchedT *sch; - uint16_t dur; - uint8_t guard; - - sch = &gVoiceSched[v]; - if (sch->done) { - return; - } - guard = 16u; - while (sch->eventFramesRem == 0u && guard != 0u) { - guard--; - if ((uint32_t)sch->chOffset + 4u >= (uint32_t)gSoundLength) { - sch->done = true; - return; - } - dur = (uint16_t)(gSoundBytes[sch->chOffset] | - ((uint16_t)gSoundBytes[sch->chOffset + 1u] << 8)); - if (dur == AGI_SOUND_END_MARKER) { - sch->done = true; - return; - } - // ScummVM order: attenuation byte first, then latch, then - // data. Critical because the noise-control byte (0xE0-0xEF) - // does NOT update _reg, so the data byte that follows must - // target whatever channel the atten byte selected. - psgWriteData(gSoundBytes[sch->chOffset + 4u]); - psgWriteData(gSoundBytes[sch->chOffset + 3u]); - psgWriteData(gSoundBytes[sch->chOffset + 2u]); - sch->chOffset = (uint16_t)(sch->chOffset + 5u); - sch->eventFramesRem = dur; - // Light per-event trace (no flush; buffered). Useful when - // diagnosing whether a "weird" note is from the SND data - // itself or from our chip emulator. - jlLogF(" agiV%u @%u dur=%u b234=%02x %02x %02x | psg ch0/1/2/N freq=%u/%u/%u/%u atten=%u/%u/%u/%u _reg=%u", - (unsigned)v, (unsigned)(sch->chOffset - 5u), (unsigned)dur, - (unsigned)gSoundBytes[sch->chOffset - 3u], - (unsigned)gSoundBytes[sch->chOffset - 2u], - (unsigned)gSoundBytes[sch->chOffset - 1u], - (unsigned)gPsg[0].freqCount, (unsigned)gPsg[1].freqCount, - (unsigned)gPsg[2].freqCount, (unsigned)gPsg[3].freqCount, - (unsigned)gPsg[0].attenuation, (unsigned)gPsg[1].attenuation, - (unsigned)gPsg[2].attenuation, (unsigned)gPsg[3].attenuation, - (unsigned)gPsgReg); - } - if (sch->eventFramesRem != 0u) { - sch->eventFramesRem = (uint16_t)(sch->eventFramesRem - 1u); - } -} - - -// Host-loop tick generator. Drives ALL AGI-time-sensitive things -// (music event advancement, v11 seconds counter increment) off the -// same wall-clock-paced tick stream. Sierra's interpreter does the -// same: one tick = one PSG event step AND one 60th of an "AGI -// second", so v11 stays in lockstep with the music regardless of -// what AGI_SOUND_TICKS_HZ is set to. Called once per host frame. -static void agiTickAdvance(void) { - static uint32_t gLastObjDumpMs = 0; - uint32_t nowMs = jlMillisElapsed(); - uint32_t deltaMs; - uint8_t v; - - // Periodic dump of currently-drawn objects so we can see what's - // visible at any moment without having to log per-opcode. - if (nowMs - gLastObjDumpMs >= 2000u || gLastObjDumpMs == 0u) { - uint8_t i; - jlLogF("--- obj dump @wallMs=%u v11=%u v36=%u v221=%u v222=%u v223=%u v224=%u v225=%u v226=%u ---", - (unsigned)nowMs, (unsigned)gVm.vars[11], - (unsigned)gVm.vars[36], - (unsigned)gVm.vars[221], (unsigned)gVm.vars[222], - (unsigned)gVm.vars[223], (unsigned)gVm.vars[224], - (unsigned)gVm.vars[225], (unsigned)gVm.vars[226]); - for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { - const AgiObjectT *o = &gVm.objects[i]; - if ((o->flags & (AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_DRAWN)) - == (AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_DRAWN)) { - jlLogF(" drawn obj[%u] view=%u loop=%u cel=%u x=%d y=%d flags=0x%02x", - (unsigned)i, (unsigned)o->viewId, (unsigned)o->loop, - (unsigned)o->cel, (int)o->x, (int)o->y, - (unsigned)o->flags); - } - } - gLastObjDumpMs = nowMs; - } - - if (gAgiTickLastWallMs == 0u) { - gAgiTickLastWallMs = nowMs; - // Prime one tick so events load immediately on song start. - gAgiTickAccumUs = AGI_TICK_PERIOD_US; - return; - } - deltaMs = nowMs - gAgiTickLastWallMs; - gAgiTickLastWallMs = nowMs; - gAgiTickAccumUs = (uint32_t)(gAgiTickAccumUs + deltaMs * 1000u); - - while (gAgiTickAccumUs >= AGI_TICK_PERIOD_US) { - gAgiTickAccumUs -= AGI_TICK_PERIOD_US; - - // Advance music if any. - if (gSoundActive) { - bool allDone; - for (v = 0u; v < AGI_SOUND_CHANNELS; v++) { - agiVoiceTick(v); - } - allDone = true; - for (v = 0u; v < AGI_SOUND_TONE_VOICES; v++) { - if (!gVoiceSched[v].done) { - allDone = false; - break; - } - } - if (allDone) { - gSoundActive = false; - jlAudioNoise(0u, 15u); // percussion must not outlive the song - jlLogF("synth EOS wallMs=%u v0done=%u v1done=%u v2done=%u v3done=%u", - (unsigned)nowMs, - (unsigned)gVoiceSched[0].done, - (unsigned)gVoiceSched[1].done, - (unsigned)gVoiceSched[2].done, - (unsigned)gVoiceSched[3].done); - jlLogFlush(); - } - } - - // v11 advances every 60 AGI ticks (= 1 AGI sec). At - // AGI_SOUND_TICKS_HZ=120 this is 2 v11 ticks per wall - // sec, which is what the KQ3 script expects (Sierra - // game-tick rate, not wall clock). - gAgiV11SubTickAccum++; - if (gAgiV11SubTickAccum >= 60u) { - gAgiV11SubTickAccum = 0u; - agiVmTickSeconds(&gVm, 1u); - } - } -} - - -// Stream-fill callback for the SB DMA path. Pure sample generator -// now -- all tick advancement is done by agiTickAdvance() from the -// host loop. The synth just reads the latest chip state cached by -// psgRecompute and emits signed 8-bit PCM. -static uint32_t agiSynthFill(void *ctx, int8_t *dst, uint32_t count) { - uint32_t i; - uint8_t v; - int sum; - int8_t voiceSample; - - (void)ctx; - if (!gSoundActive) { - return 0u; - } - for (i = 0u; i < count; i++) { - sum = 0; - for (v = 0u; v < AGI_SOUND_TONE_VOICES; v++) { - voiceSample = (gPsg[v].phase & 0x8000u) - ? gPsg[v].amplitude - : (int8_t)(-gPsg[v].amplitude); - sum += voiceSample; - gPsg[v].phase = (uint16_t)(gPsg[v].phase + gPsg[v].phaseInc); - } - if (sum > 127) { sum = 127; } - if (sum < -128) { sum = -128; } - dst[i] = (int8_t)sum; - } - return count; -} - - -static void cbPlaySound(void *ctx, uint8_t soundId) { - uint8_t *bytes; - uint16_t length; - uint8_t v; - uint16_t off; - - (void)ctx; - agiSoundReset(); - if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) { - return; - } - bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length); - if (bytes == NULL) { - return; - } - if (length < 8u) { - free(bytes); - return; - } - gSoundBytes = bytes; - gSoundLength = length; - jlLogF("cbPlaySound id=%u len=%u offs=%u/%u/%u/%u wallMs=%u", - (unsigned)soundId, (unsigned)length, - (unsigned)(bytes[0] | (bytes[1] << 8)), - (unsigned)(bytes[2] | (bytes[3] << 8)), - (unsigned)(bytes[4] | (bytes[5] << 8)), - (unsigned)(bytes[6] | (bytes[7] << 8)), - (unsigned)jlMillisElapsed()); - jlLogFlush(); - for (v = 0u; v < AGI_SOUND_CHANNELS; v++) { - off = (uint16_t)(bytes[v * 2u] | ((uint16_t)bytes[v * 2u + 1u] << 8)); - gVoiceSched[v].chOffset = off; - gVoiceSched[v].eventFramesRem = 0u; - gVoiceSched[v].done = (off == 0u) || ((uint32_t)off + 4u >= (uint32_t)length); - } - // Chip state was zeroed by agiSoundReset above; nothing more - // to init here. First agiVoiceTick will fire the events that - // configure freqCount / attenuation / genType. - // Wall-clock-driven tick scheduler: cleared in agiSoundReset - // above; agiSynthFill's first invocation primes the timestamp. - gSoundActive = true; - // Push the synth into SFX slot 0 of the SB DMA mixer at the - // exact output rate so no resampling happens between the synth - // and the chip. - jlAudioPlaySfxStream(0u, agiSynthFill, NULL, AGI_SYNTH_SAMPLE_RATE); -} - - -// VM polls this each cycle to detect natural sound completion. -// True while any tone voice still has events left; false once -// every voice has run off its 0xFFFF terminator (or stopSound -// was called). -static bool cbIsPlayingSound(void *ctx) { - (void)ctx; - return gSoundActive; -} - - -static void cbStopSound(void *ctx) { - (void)ctx; - jlLog("cbStopSound"); - jlLogFlush(); - agiSoundReset(); -} - - -// View metadata callbacks for the VM's cycle / last.cel / -// number.of.loops opcodes. fetchView walks the view-cache, faulting -// the view in from VOL on a cold hit, so the first call after a -// view is referenced may decode the resource. Returns 0 if the -// view cannot be loaded; the VM treats 0 as "still loading" (no -// premature end.of.loop fire) and the host renderer's own clamps -// keep the screen from drawing garbage cels. -static uint8_t cbViewCelCount(void *ctx, uint8_t viewId, uint8_t loopId) { - const AgiViewT *view; - - (void)ctx; - view = fetchView(viewId); - if (view == NULL || loopId >= AGI_MAX_LOOPS_PER_VIEW) { - return 0u; - } - return view->loops[loopId].celCount; -} - - -static uint8_t cbViewLoopCount(void *ctx, uint8_t viewId) { - const AgiViewT *view; - - (void)ctx; - view = fetchView(viewId); - if (view == NULL) { - return 0u; - } - return view->loopCount; -} - - -// (Pre-render synthesizeSnd path was replaced by the streaming -// generator agiSoundFill above; see that for the SND event format -// and PSG synthesis details.) -#if 0 -static bool synthesizeSnd_removed(const uint8_t *bytes, uint16_t length, uint8_t **outPcm, uint32_t *outLen) { - uint32_t maxTicks; - uint32_t durSamples; - uint8_t *pcm; - uint8_t ch; - - if (length < 8u) { - return false; - } - maxTicks = 0u; - for (ch = 0u; ch < 4u; ch++) { - uint16_t off; - uint16_t i; - uint32_t ticks; - - off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8)); - if (off >= length) { - continue; - } - ticks = 0u; - i = off; - while ((uint32_t)i + 4u < (uint32_t)length) { - uint16_t dur; - - dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8)); - if (dur == 0xFFFFu) { - break; - } - ticks = (uint32_t)(ticks + dur); - i = (uint16_t)(i + 5u); - } - if (ticks > maxTicks) { - maxTicks = ticks; - } - } - if (maxTicks == 0u) { - return false; - } - durSamples = (maxTicks * (uint32_t)SOUND_SAMPLE_RATE) / 60u; - if (durSamples == 0u) { - return false; - } - // audioSfxOverlayMix uses a 16.16 fixed-point position counter; - // input samples beyond 2^16 - 1 cause pos to wrap and the slot - // to loop the buffer forever instead of completing. Truncate - // here so longer sounds at least play their opening bars. - if (durSamples > SOUND_MAX_SAMPLES) { - durSamples = SOUND_MAX_SAMPLES; - } - pcm = (uint8_t *)malloc(durSamples); - if (pcm == NULL) { - return false; - } - memset(pcm, 0, durSamples); // signed silence = 0 - - for (ch = 0u; ch < SOUND_SYNTH_CHANNELS; ch++) { - uint16_t off; - uint16_t i; - uint32_t sampleIdx; - // 16.16 phase accumulator: 0..0xFFFF = one square-wave - // cycle. Stepping by phaseStep per output sample yields - // sub-sample precision so 717 Hz, 466 Hz, etc. stay at the - // correct frequency instead of quantizing to the nearest - // integer half-period (which scrambled the melody at - // lower sample rates). - uint32_t phaseAcc; - - off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8)); - if (off >= length) { - continue; - } - sampleIdx = 0u; - phaseAcc = 0u; - i = off; - while ((uint32_t)i + 4u < (uint32_t)length && sampleIdx < durSamples) { - uint16_t dur; - uint16_t divisor; - uint8_t atten; - int8_t amp; - uint32_t durSamps; - uint32_t phaseStep; - uint32_t s; - - dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8)); - if (dur == 0xFFFFu) { - break; - } - divisor = (uint16_t)(((bytes[i + 2u] & 0x3Fu) << 4) | (bytes[i + 3u] & 0x0Fu)); - atten = (uint8_t)(bytes[i + 4u] & 0x0Fu); - amp = kAgiSoundAmp[atten]; - durSamps = ((uint32_t)dur * (uint32_t)SOUND_SAMPLE_RATE) / 60u; - if (divisor == 0u || amp == 0) { - // Silent event -- just advance sampleIdx without - // touching pcm (already zeroed) or stepping phase. - if (sampleIdx + durSamps > durSamples) { - sampleIdx = durSamples; - } else { - sampleIdx = sampleIdx + durSamps; - } - i = (uint16_t)(i + 5u); - continue; - } - // freq_hz = PSG_CLOCK_HZ / divisor. - // phaseStep (16.16, one cycle = 0x10000) per output sample - // = (freq_hz << 16) / SOUND_SAMPLE_RATE - // = (PSG_CLOCK_HZ << 16) / (divisor * SOUND_SAMPLE_RATE) - phaseStep = ((uint32_t)PSG_CLOCK_HZ << 16) / ((uint32_t)divisor * SOUND_SAMPLE_RATE); - for (s = 0u; s < durSamps && sampleIdx < durSamples; s++, sampleIdx++) { - int8_t sval; - int16_t mixed; - - sval = (phaseAcc & 0x8000u) ? (int8_t)(-amp) : amp; - mixed = (int16_t)((int8_t)pcm[sampleIdx]) + sval; - if (mixed > 127) { mixed = 127; } - if (mixed < -128) { mixed = -128; } - pcm[sampleIdx] = (uint8_t)mixed; - phaseAcc = (uint32_t)((phaseAcc + phaseStep) & 0xFFFFu); - } - i = (uint16_t)(i + 5u); - } - } - - *outPcm = pcm; - *outLen = durSamples; - return true; -} - - -static void cbPlaySound(void *ctx, uint8_t soundId) { - uint8_t *bytes; - uint16_t length; - uint8_t *pcm; - uint32_t pcmLen; - - (void)ctx; - if (!gAudioReady) { - return; - } - // Cache hit: same sound replayed -- just restart the slot, - // skip the file load + synth (instant restart, no freeze). - if (gSoundPcmValid && gSoundPcm != NULL && gSoundPcmId == soundId) { - jlAudioStopSfx(0u); - jlAudioPlaySfx(0u, gSoundPcm, gSoundPcmLen, (uint16_t)SOUND_SAMPLE_RATE); - return; - } - // Cache miss: tear down prior buffer, synth a new one. - if (gSoundPcm != NULL) { - jlAudioStopSfx(0u); - free(gSoundPcm); - gSoundPcm = NULL; - gSoundPcmLen = 0u; - } - gSoundPcmValid = false; - if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) { - return; - } - bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length); - if (bytes == NULL) { - return; - } - pcm = NULL; - pcmLen = 0u; - if (synthesizeSnd(bytes, length, &pcm, &pcmLen)) { - gSoundPcm = pcm; - gSoundPcmLen = pcmLen; - gSoundPcmId = soundId; - gSoundPcmValid = true; - jlAudioPlaySfx(0u, gSoundPcm, gSoundPcmLen, (uint16_t)SOUND_SAMPLE_RATE); - } - free(bytes); -} - - -static void cbStopSound(void *ctx) { - (void)ctx; - if (!gAudioReady) { - return; - } - // Stop the slot but keep the PCM cached -- a quick stop/start - // (very common in AGI v2 state machines) then replays without - // a re-synth freeze. - jlAudioStopSfx(0u); -} -#endif - - -// Parse an AGI v2 SND resource and return its playback duration in -// milliseconds (max across the 4 PSG channels). Used to defer the -// sound-done flag firing until the music would naturally end -- -// drives KQ3's title state-1 / state-3 shortcut which advances the -// countdown to 2 sec once flag 9 (sound finished) is set. -// -// SND v2 layout: -// bytes 0..7 : 4 channel offsets (2-byte LE each) -// per channel : sequence of 5-byte events (duration LE16, freq LE16, -// attenuation), terminated by duration = 0xFFFF. -// Duration units are 1/60 sec (NTSC vblank ticks). -static uint32_t cbSoundDuration(void *ctx, uint8_t soundId) { - uint8_t *bytes; - uint16_t length; - uint32_t maxTicks; - uint8_t ch; - - (void)ctx; - if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) { - return 0u; - } - bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length); - if (bytes == NULL) { - return 0u; - } - if (length < 8u) { - free(bytes); - return 0u; - } - maxTicks = 0u; - for (ch = 0u; ch < 4u; ch++) { - uint16_t off; - uint16_t i; - uint32_t ticks; - - off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8)); - ticks = 0u; - i = off; - while ((uint32_t)i + 4u < (uint32_t)length) { - uint16_t dur; - - dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8)); - if (dur == 0xFFFFu) { - break; - } - ticks = (uint32_t)(ticks + dur); - i = (uint16_t)(i + 5u); - } - if (ticks > maxTicks) { - maxTicks = ticks; - } - } - free(bytes); - // 60 ticks / sec -> ms = ticks * 1000 / 60 = ticks * 50 / 3 - return (maxTicks * 1000u) / 60u; -} - - -static bool decodePicInto(uint8_t picId, bool clearFirst) { - uint8_t *bytes; - uint16_t length; - bool ok; - - if ((uint16_t)picId >= gGame.resCount[AGI_RES_PIC]) { - return false; - } - bytes = agiResLoad(&gGame, AGI_RES_PIC, (uint16_t)picId, &length); - if (bytes == NULL) { - return false; - } - if (clearFirst) { - agiPicClear(&gPic); - } - ok = agiPicDecode(&gPic, bytes, length); - free(bytes); - return ok; -} - - -// Walk every key the JoeyLib enum knows about; for each one that -// went down this frame, look it up against vm->keyBindings via -// agiVmDispatchKey. Function keys map to the AGI extended scancode -// pair (0, 59..68); printable ASCII keys map to (asciiCode, 0). -static void dispatchAllPressedKeys(void) { - static const uint8_t fnScancodes[10] = { 59, 60, 61, 62, 63, 64, 65, 66, 67, 68 }; - static const jlKeyE fnKeys[10] = { - KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, - KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10 - }; - uint8_t k; - uint8_t i; - bool captureText; - - // While the command line is live (and no modal is up), printable keys, - // Enter, Space and Backspace belong to the input line (agiInputCapture), - // not to controller/key-binding dispatch. Tab, function keys and the - // joystick stay as controllers so status/menu/action keys still work. - captureText = gVm.acceptInput && !gVm.printModal.active; - - if (!captureText) { - if (jlKeyPressed(KEY_RETURN)) { - agiVmDispatchKey(&gVm, 13u, 0u); - gAnyKeyHit = true; - } - if (jlKeyPressed(KEY_SPACE)) { - agiVmDispatchKey(&gVm, 32u, 0u); - gAnyKeyHit = true; - } - if (jlKeyPressed(KEY_BACKSPACE)) { - agiVmDispatchKey(&gVm, 8u, 0u); - gAnyKeyHit = true; - } - for (k = 0u; k < 26u; k++) { - if (jlKeyPressed((jlKeyE)((uint8_t)KEY_A + k))) { - agiVmDispatchKey(&gVm, (uint8_t)('a' + k), 0u); - gAnyKeyHit = true; - } - } - for (k = 0u; k < 10u; k++) { - if (jlKeyPressed((jlKeyE)((uint8_t)KEY_0 + k))) { - agiVmDispatchKey(&gVm, (uint8_t)('0' + k), 0u); - gAnyKeyHit = true; - } - } - } - if (jlKeyPressed(KEY_TAB)) { - agiVmDispatchKey(&gVm, 9u, 0u); - gAnyKeyHit = true; - } - for (i = 0u; i < 10u; i++) { - if (jlKeyPressed(fnKeys[i])) { - agiVmDispatchKey(&gVm, 0u, fnScancodes[i]); - gAnyKeyHit = true; - } - } - if (jlJoystickConnected(JOYSTICK_0)) { - if (jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0) || jlJoyPressed(JOYSTICK_0, JOY_BUTTON_1)) { - agiVmDispatchKey(&gVm, 13u, 0u); // joystick fire == ENTER - gAnyKeyHit = true; - } - } -} - - -// Restore the exact stage rect `(x, y, w, h)` from the gBackdrop -// surface. Chunky-fast: a single memcpy per row from backdrop pixels -// to stage pixels via the library's rect copy (hunt-3 rank 3). The -// old body hand-rolled a chunky memcpy loop and, on the planar 68k -// ports (stage->pixels NULL post-Phase-9/10), fell back to a FULL -// 32KB jlSurfaceCopy per call -- 3-5 times per frame -- which also -// re-marked the whole stage AND the SCB/palette dirty, defeating the -// group-skip presents. jlSurfaceCopyRect does everything this needs: -// clips, widens x to the 16-px group grid (safe -- the erase-all- -// then-redraw-all object pass repaints anything the widening grazes), -// routes to each port's fast copy (ST move.l spans, Amiga blitter -// arm, IIgs inline word, DOS memcpy), and marks exactly the rect. -static void restoreRectFromBackdrop(jlSurfaceT *stage, int16_t x, int16_t y, int16_t w, int16_t h) { - if (stage == NULL || gBackdrop == NULL || w <= 0 || h <= 0) { - return; - } - jlSurfaceCopyRect(stage, gBackdrop, x, y, (uint16_t)w, (uint16_t)h); -} - - -// Walk vm->objects[] and (re)draw every animated+drawn object. Per -// frame: -// pass 1: restore the prev-frame rect of every previously-drawn -// obj from the backdrop. Erases the prev draw without -// needing per-object save buffers. -// pass 2: for each visible obj, compute its cel's stage bbox, -// draw the cel, and record the bbox as its new prev rect. -// -// Two passes are required so an obj's restore can't accidentally -// erase another obj's draw -- all restores happen before any draws. -static void drawObjects(jlSurfaceT *stage) { - uint8_t i; - - // Pass 1: restore prev-frame rect from backdrop for every obj - // that was drawn last frame. - for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { - if (gObjPrev[i].valid) { - restoreRectFromBackdrop(stage, gObjPrev[i].x, gObjPrev[i].y, - gObjPrev[i].w, gObjPrev[i].h); - gObjPrev[i].valid = false; - } - } - - // Pass 2: draw current frame's visible objects. - for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { - const AgiObjectT *obj; - const AgiViewT *view; - uint8_t loop; - uint8_t cel; - uint8_t actorPri; - int16_t stageX; - int16_t stageY; - int16_t stageW; - int16_t stageH; - - obj = &gVm.objects[i]; - if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) { - continue; - } - if ((obj->flags & AGI_OBJ_FLAG_DRAWN) == 0u) { - continue; - } - view = fetchView(obj->viewId); - if (view == NULL || view->loopCount == 0u) { - continue; - } - loop = obj->loop; - // Auto-loop from direction only applies to MOVING objects. - // A stopped obj keeps whatever loop the script (e.g. - // set.loop) assigned -- otherwise KQ3's title-screen wizard - // eyes (obj 0, view=65 loop=1, direction=0) get clobbered - // to loop 0 (the body view) by the stopped-default branch. - if ((obj->flags & AGI_OBJ_FLAG_LOOP_FIXED) == 0u && - obj->direction != 0u) { - uint8_t autoLoop = loopForDirection(obj->direction, view->loopCount); - if (autoLoop < view->loopCount) { - loop = autoLoop; - } - } - if (loop >= view->loopCount) { - loop = 0u; - } - cel = obj->cel; - if (cel >= view->loops[loop].celCount) { - cel = 0u; - } - if ((obj->flags & AGI_OBJ_FLAG_PRI_FIXED) != 0u && obj->priority >= AGI_PRIORITY_BG) { - actorPri = obj->priority; - } else { - actorPri = agiActorPriorityForY(obj->y); - } - agiViewDraw(view, loop, cel, - (int16_t)obj->x, (int16_t)obj->y, - actorPri, gPic.priority, - stage, PIC_DEST_Y); - // Record the cel's exact stage bbox so next frame's restore - // covers only that region (works for cels of any size, fixes - // the trailing-pixel artifact wider/taller cels suffered with - // the previous fixed-size save-under). - { - uint8_t cw = view->loops[loop].cels[cel].width; - uint8_t ch = view->loops[loop].cels[cel].height; - stageX = (int16_t)(obj->x * 2); - stageY = (int16_t)(PIC_DEST_Y + obj->y - (int16_t)ch + 1); - stageW = (int16_t)(cw * 2); - stageH = (int16_t)ch; - gObjPrev[i].x = stageX; - gObjPrev[i].y = stageY; - gObjPrev[i].w = stageW; - gObjPrev[i].h = stageH; - gObjPrev[i].valid = true; - } - } -} - - -// Invalidate every object's prev-rect entry. Called when the picture -// changes (show.pic / add.to.pic) so we don't restore stale pixels -// over the new backdrop on the next frame. -static void invalidateObjectSaves(void) { - uint8_t i; - - for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { - gObjPrev[i].valid = false; - } -} - - -static const AgiViewT *fetchView(uint8_t viewId) { - ViewCacheSlotT *slot; - uint8_t *bytes; - uint16_t length; - - slot = &gViewCache[viewId]; - if (slot->loaded) { - return &slot->view; - } - if (viewId >= gGame.resCount[AGI_RES_VIEW]) { - return NULL; - } - bytes = agiResLoad(&gGame, AGI_RES_VIEW, (uint16_t)viewId, &length); - if (bytes == NULL) { - return NULL; - } - if (!agiViewParse(&slot->view, bytes, length)) { - return NULL; - } - slot->loaded = true; - return &slot->view; -} - - -// AGI's canonical loop assignment from direction (idx 0 = right): -// right=0, left=1, down=2, up=3 -// Diagonal directions reuse the dominant axis loop. -static uint8_t loopForDirection(uint8_t direction, uint8_t loopCount) { - uint8_t loop; - - if (loopCount <= 1u) { - return 0u; - } - switch (direction) { - case 1u: loop = 3u; break; // N - case 2u: loop = 0u; break; // NE -> right - case 3u: loop = 0u; break; // E - case 4u: loop = 0u; break; // SE -> right - case 5u: loop = 2u; break; // S - case 6u: loop = 1u; break; // SW -> left - case 7u: loop = 1u; break; // W - case 8u: loop = 1u; break; // NW -> left - default: loop = 2u; break; // stopped -> down - } - if (loop >= loopCount) { - return 0u; - } - return loop; -} - - -static uint8_t parseArgU8(const char *s) { - uint16_t val; - uint8_t digit; - - if (s == NULL) { - return 0u; - } - val = 0u; - while (*s != '\0') { - if (*s < '0' || *s > '9') { - return 0u; - } - digit = (uint8_t)(*s - '0'); - if (val > 25u || (val == 25u && digit > 5u)) { - return 0u; - } - val = (uint16_t)(val * 10u + digit); - s++; - } - return (uint8_t)val; -} - - -static void releaseLogicCache(void) { - uint16_t i; - - for (i = 0u; i < AGI_MAX_RESOURCES; i++) { - if (gLogicCache[i].raw != NULL) { - free(gLogicCache[i].raw); - gLogicCache[i].raw = NULL; - gLogicCache[i].bytecode = NULL; - } - } -} - - -static void releaseViewCache(void) { - uint16_t i; - - for (i = 0u; i < AGI_MAX_RESOURCES; i++) { - if (gViewCache[i].loaded) { - agiViewFree(&gViewCache[i].view); - gViewCache[i].loaded = false; - } - } -} - - -static void renderFrame(jlSurfaceT *stage) { - uint8_t r; - - if (!gPicReady) { - jlStagePresent(); - return; - } - // Step 1: restore text rows that had content last frame so a VM - // that cleared them this frame (clear.lines / row reassign) sees - // them disappear from the stage. - for (r = 0u; r < (uint8_t)AGI_TEXT_ROWS; r++) { - if (gTextRowDirty[r]) { - restoreRectFromBackdrop(stage, 0, (int16_t)((int16_t)r * 8), - (int16_t)SURFACE_WIDTH, 8); - gTextRowDirty[r] = false; - } - } - // Step 2: restore + draw animated/drawn objects. - drawObjects(stage); - // Command line: echo "> " + the typed text (with a cursor) on the input - // row while accept.input is live; blank our prompt when it turns off so - // the line disappears. This reuses the text-row plane so agiTextRender - // and the dirty-row bookkeeping below draw and wipe it like any message. - { - char *dst = gVm.textRows[AGI_INPUT_ROW].text; - - if (gVm.acceptInput) { - uint8_t n; - uint8_t i; - - n = 0u; - dst[n++] = '>'; - dst[n++] = ' '; - for (i = 0u; i < gVm.inputLen && n < (uint8_t)AGI_TEXT_COLS; i++) { - dst[n++] = gVm.inputLine[i]; - } - if (n < (uint8_t)AGI_TEXT_COLS) { - dst[n++] = '_'; - } - dst[n] = '\0'; - } else if (dst[0] == '>') { - dst[0] = '\0'; - } - } - // Step 3: draw current text rows on top. Mark dirty as we go so - // step 1 of the next frame knows to wipe them. - { - bool statusOn = gVm.statusLineOn; - uint8_t statusRow = gVm.statusLineRow; - - if (statusOn) { - gTextRowDirty[statusRow] = true; - } - for (r = 0u; r < (uint8_t)AGI_TEXT_ROWS; r++) { - if (gVm.textRows[r].text[0] != '\0') { - gTextRowDirty[r] = true; - } - } - } - agiTextRender(&gVm, stage); - jlStagePresent(); -} - - -static uint8_t resolveStartingRoom(int argc, char **argv) { -#if defined(JOEYLIB_PLATFORM_IIGS) - // GS/OS launches an S16 app with no usable argv (the crt0 leaves - // argc/argv uninitialized -- garbage), so always start at the title - // room. Game data comes from DATA/ regardless of argv now. - (void)argc; - (void)argv; - return 0u; -#else - if (argc <= 1 || argv[1] == NULL) { - return 0u; - } - return parseArgU8(argv[1]); -#endif -} - - -static void runVmCycle(void) { - AgiVmHaltE reason; - const uint8_t *logic0; - uint16_t length; - uint8_t prevRoom; - uint8_t i; - uint8_t guard; - - // Block the cycle entirely while a print() modal is up; the - // host's input loop dismisses it on the next ack key. - if (gVm.printModal.active) { - return; - } - - guard = CYCLE_HALT_GUARD; + inputProcess(); + // 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 + // ego never does). + ego = &gAgi->objects[AGI_EGO]; + steerEgo(ego); + objPlanMotion(); + textUpdateStatus(); for (;;) { - if (guard == 0u) { - return; + gAgi->abortLogic = false; + vmCallLogic(0u); + if (!gAgi->newRoomPending) { + break; } - guard--; - reason = agiVmRun(&gVm); - if (reason == AGI_VM_HALT_PRINT_PENDING) { - return; - } - if (reason == AGI_VM_HALT_QUIT) { - return; - } - if (reason == AGI_VM_HALT_RETURN) { - agiVmTickAnimation(&gVm); - gVm.flags[5] = 0u; - // The player's command was offered to every logic this cycle; - // consume it so said() can't re-fire it on later cycles. (A - // matching said() already cleared it; this covers the no-match - // case.) - gVm.flags[AGI_FLAG_INPUT] = 0u; - length = 0u; - logic0 = cbFetchLogic(NULL, 0u, &length); - if (logic0 != NULL) { - agiVmResetToLogic(&gVm, logic0, length, 0u); - } - return; - } - if (reason == AGI_VM_HALT_NEW_ROOM) { - prevRoom = gVm.vars[0]; - if (gOverrideRoomActive) { - gVm.vars[0] = gOverrideRoom; - gOverrideRoomActive = false; - } else { - gVm.vars[0] = gVm.newRoomId; - } - gVm.vars[1] = prevRoom; - for (i = 0u; i < 32u; i++) { - gVm.flags[i] = 0u; - } - gVm.flags[5] = 1u; - // Clear the object table on room change so leftover NPCs - // don't bleed into the new scene. Ego (object 0) stays - // animated/drawn; the new room's init re-runs animate.obj - // with the same setup if it wants ego on screen. - for (i = 1u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { - agiObjReset(&gVm.objects[i]); - } - // Clear text rows. Sierra's new.room expects a fresh text - // band -- without this, v226=3 credits from LOGIC.45 stay - // visible after the room.46 transition. - for (i = 0u; i < (uint8_t)AGI_TEXT_ROWS; i++) { - gVm.textRows[i].text[0] = '\0'; - } - length = 0u; - logic0 = cbFetchLogic(NULL, 0u, &length); - if (logic0 != NULL) { - agiVmResetToLogic(&gVm, logic0, length, 0u); - } - continue; - } - // Unknown opcode, truncated stream, missing logic, stack - // overflow: stop cycling this frame and let the host render - // what we have. + changeRoom(); + } + gAgi->abortLogic = false; + // A restore ends the cycle as it was saved; logic 0 sees f12 next. + if (gAgi->restored) { + gAgi->restored = false; return; } + steerEgo(ego); + textUpdateStatus(); + gAgi->vars[VAR_OBJ_EDGE_OBJ] = 0u; + gAgi->vars[VAR_OBJ_EDGE] = 0u; + gAgi->flags[FLAG_NEW_ROOM] = 0u; + gAgi->flags[FLAG_RESTART] = 0u; + gAgi->flags[FLAG_RESTORED] = 0u; + gAgi->flags[FLAG_FIRST_LOGIC0] = 0u; + if (!gAgi->textMode) { + objUpdateAll(); + gfxPresentDirty(); + } } -// Player-control input mapping for ego (when programControl is false). -// Sets var 6 (ego direction) so the engine's motion tick moves the -// player normally. Diagonals not bound; AGI canonical games rarely -// use them via keyboard. -static void updateEgoFromInput(void) { - uint8_t newDir; +static void steerEgo(AgiObjectT *ego) { + if (ego->motion == AGI_MOTION_NORMAL) { + ego->direction = gAgi->vars[VAR_EGO_DIR]; + } +} - if (gVm.programControl) { + +// v11..v14 count real time. +static void tickClock(void) { + uint32_t now; + + now = jlMillisElapsed(); + gClockMs = gClockMs + (now - gClockLastMs); + gClockLastMs = now; + while (gClockMs >= MS_PER_SECOND) { + gClockMs -= MS_PER_SECOND; + gAgi->vars[VAR_SECONDS]++; + if (gAgi->vars[VAR_SECONDS] < SECONDS_PER_MINUTE) { + continue; + } + gAgi->vars[VAR_SECONDS] = 0u; + gAgi->vars[VAR_MINUTES]++; + if (gAgi->vars[VAR_MINUTES] < MINUTES_PER_HOUR) { + continue; + } + gAgi->vars[VAR_MINUTES] = 0u; + gAgi->vars[VAR_HOURS]++; + if (gAgi->vars[VAR_HOURS] < HOURS_PER_DAY) { + continue; + } + gAgi->vars[VAR_HOURS] = 0u; + gAgi->vars[VAR_DAYS]++; + } +} + + +// ----- Public API (alphabetical) ----- + +// One frame of everything that runs while the game waits: the frame +// passes first, so the keys, the sound and the clock are up to date when +// a cycle starts right after it (as the PC's timer interrupt keeps them). +void agiIdle(void) { + jlWaitVBL(); + inputPoll(); + soundPump(); + tickClock(); + jlStagePresent(); + jlAudioFrameTick(); +} + + +// new.room: logic stops; the room changes before logic 0 runs again. +void agiNewRoom(uint8_t room) { + gAgi->newRoomPending = true; + gAgi->newRoomNumber = room; + gAgi->abortLogic = true; +} + + +// obj.status.v: an object's numbers, for debugging. +void agiObjectStatus(uint8_t n) { + char text[STATUS_TEXT_MAX]; + const AgiObjectT *o; + + if (n >= gAgi->objectCount) { return; } - newDir = 0u; - if (jlKeyDown(KEY_UP)) { newDir = 1u; } - if (jlKeyDown(KEY_RIGHT)) { newDir = 3u; } - if (jlKeyDown(KEY_DOWN)) { newDir = 5u; } - if (jlKeyDown(KEY_LEFT)) { newDir = 7u; } - if (newDir != 0u || jlKeyPressed(KEY_UP) || jlKeyPressed(KEY_DOWN) || jlKeyPressed(KEY_LEFT) || jlKeyPressed(KEY_RIGHT)) { - gVm.objects[0].direction = newDir; - gVm.vars[6] = newDir; - } else if (!jlKeyDown(KEY_UP) && !jlKeyDown(KEY_DOWN) && !jlKeyDown(KEY_LEFT) && !jlKeyDown(KEY_RIGHT)) { - gVm.objects[0].direction = 0u; - gVm.vars[6] = 0u; + o = &gAgi->objects[n]; + snprintf(text, sizeof(text), "Object %u:\nx: %d y: %d pri: %u\nstepsize: %u", + (unsigned)n, (int)o->x, (int)o->y, (unsigned)o->priority, (unsigned)o->stepSize); + textMessageBox(text); +} + + +// quit: at once, or after asking. +void agiQuit(bool immediately) { + if (!immediately) { + textPrint("Press ENTER to quit.\nPress ESC to keep playing.", -1, -1, 0u); + if (!inputWaitPrintClose(0u)) { + textCloseWindow(); + return; + } + textCloseWindow(); } + gAgi->quitRequested = true; + gAgi->abortLogic = true; +} + + +// 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. +void agiResetState(void) { + uint16_t i; + + memset(gAgi->vars, 0, sizeof(gAgi->vars)); + memset(gAgi->flags, 0, sizeof(gAgi->flags)); + memset(gAgi->strings, 0, sizeof(gAgi->strings)); + memset(gAgi->controllers, 0, sizeof(gAgi->controllers)); + vmDiscardLogics(); + gAgi->logicLoaded[0] = true; + gAgi->logicScanStart[0] = 0u; + objClearAll(); + for (i = 0u; i < AGI_MAX_ITEMS; i++) { + gAgi->itemRoom[i] = agiItemStartRoom(&gAgi->game, (uint8_t)i); + } + gAgi->menuRequested = false; + gAgi->horizon = AGI_DEFAULT_HORIZON; + gAgi->blockActive = false; + gAgi->playerControl = true; + gAgi->picRow = DEFAULT_PIC_ROW; + gAgi->inputRow = DEFAULT_INPUT_ROW; + gAgi->statusRow = DEFAULT_STATUS_ROW; + gAgi->statusOn = false; + gAgi->inputOn = false; + gAgi->textMode = false; + gAgi->textFg = AGI_COLOR_WHITE; + gAgi->textBg = AGI_COLOR_BLACK; + gAgi->cursorChar = AGI_NO_CURSOR; + gAgi->windowOpen = false; + gAgi->inputLine[0] = '\0'; + gAgi->inputLen = 0u; + gAgi->lastLine[0] = '\0'; + gAgi->wordCount = 0u; + gAgi->newRoomPending = false; + gAgi->abortLogic = false; + gAgi->vars[VAR_CYCLE_DELAY] = DEFAULT_CYCLE_DELAY; + gAgi->vars[VAR_INPUT_MAX] = DEFAULT_INPUT_MAX; + gAgi->vars[VAR_FREE_PAGES] = DEFAULT_FREE_PAGES; + gAgi->vars[VAR_COMPUTER] = COMPUTER_IBM_PC; + gAgi->vars[VAR_SOUND_TYPE] = SOUND_PC; + gAgi->vars[VAR_MONITOR] = MONITOR_EGA; + gAgi->flags[FLAG_FIRST_LOGIC0] = 1u; + gAgi->flags[FLAG_SOUND] = 1u; + saveScriptClear(); + inputReset(); +} + + +// restart.game: after asking, start over with f6 set. +void agiRestartGame(void) { + textPrint("Press ENTER to restart\nthe game.\n\nPress ESC to continue\nthis game.", -1, -1, 0u); + if (!inputWaitPrintClose(0u)) { + textCloseWindow(); + return; + } + textCloseWindow(); + gAgi->restartRequested = true; + gAgi->abortLogic = true; } // ----- Entry point ----- int main(int argc, char **argv) { - jlConfigT cfg; - AgiVmCallbacksT cb; - const uint8_t *logic0; - uint16_t length; - uint8_t startingRoom; - bool picOk; - bool vmReady; + jlConfigT cfg; + uint32_t lastCycleMs; + uint32_t now; + bool soundWasOn; - cfg.codegenBytes = 0u; // no sprites needed -- backdrop-restore path is direct memcpy - // AGI sound is software-synthesized PSG squarewaves pushed - // through the Sound Blaster auto-init DMA path (see agiSynthFill - // and src/port/dos/audio.c). The audio pool covers the 4 KB - // DMA buffer + libxmp-lite SMIX state. + (void)argc; + (void)argv; + memset(&cfg, 0, sizeof(cfg)); if (!jlInit(&cfg)) { return 1; } - jlLogReset(); - jlLogF("AGI TRACE build=%s %s", __DATE__, __TIME__); - jlLogFlush(); - gAgiStage = jlStageGet(); - jlPaletteSet(gAgiStage, 0u, kAgiPalette); - jlRandomSeed(0xA61DA61Du); - - // Bring up the SB DMA + mixer. AGI music feeds the SFX stream - // path inside jlAudioFrameTick, which depends on a successful - // init here. - (void)jlAudioInit(); - - { - uint8_t i; - for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { - gObjPrev[i].valid = false; - } - } - - - // The view cache is too large for bank-0 BSS on the IIgs; pull it - // from the platform's large-allocator. jlAlloc does not zero its - // result, so clear it so every slot starts with loaded == false. - gViewCache = (ViewCacheSlotT *)jlAlloc(sizeof(ViewCacheSlotT) * AGI_MAX_RESOURCES); - if (gViewCache == NULL) { + jlPaletteSet(jlStageGet(), 0u, kAgiPalette); + jlSurfaceClear(jlStageGet(), AGI_COLOR_BLACK); + jlRandomSeed(RANDOM_SEED); + gAgi = (AgiStateT *)jlAlloc(sizeof(AgiStateT)); + if (gAgi == NULL) { jlShutdown(); return 1; } - memset(gViewCache, 0, sizeof(ViewCacheSlotT) * AGI_MAX_RESOURCES); - - startingRoom = resolveStartingRoom(argc, argv); - gOverrideRoom = startingRoom; - gOverrideRoomActive = (startingRoom != 0u); - picOk = false; - vmReady = false; - gBackdrop = NULL; - gPicReady = false; - gAnyKeyHit = false; - - if (agiResOpen(&gGame)) { - picOk = agiPicAlloc(&gPic); - if (picOk) { - agiPicClear(&gPic); - gBackdrop = jlSurfaceCreate(); - - agiVmInit(&gVm); - cb.fetchLogic = cbFetchLogic; - cb.loadPic = cbLoadPic; - cb.drawPic = cbDrawPic; - cb.showPic = cbShowPic; - cb.discardPic = cbDiscardPic; - cb.overlayPic = cbOverlayPic; - cb.haveKey = cbHaveKey; - cb.fetchMessage = cbFetchMessage; - cb.addToPic = cbAddToPic; - cb.soundDuration = cbSoundDuration; - cb.playSound = cbPlaySound; - cb.stopSound = cbStopSound; - cb.isPlayingSound = cbIsPlayingSound; - cb.viewCelCount = cbViewCelCount; - cb.viewLoopCount = cbViewLoopCount; - cb.ctx = NULL; - agiVmSetCallbacks(&gVm, &cb); - - length = 0u; - logic0 = cbFetchLogic(NULL, 0u, &length); - if (logic0 != NULL) { - agiVmResetToLogic(&gVm, logic0, length, 0u); - vmReady = true; - } + memset(gAgi, 0, sizeof(AgiStateT)); + if (!agiResOpen(&gAgi->game) || !gfxInit()) { + jlLog("AGI: the game's files are missing from DATA/"); + jlLogFlush(); + jlShutdown(); + return 1; + } + gAgi->objectCount = gAgi->game.maxObjects; + soundInit(); + agiResetState(); + gClockLastMs = jlMillisElapsed(); + lastCycleMs = gClockLastMs; + while (!gAgi->quitRequested) { + cycle(); + if (gAgi->restartRequested) { + soundWasOn = gAgi->flags[FLAG_SOUND] != 0u; + soundStop(); + gfxEraseAll(); + agiResetState(); + gAgi->restartRequested = false; + gAgi->flags[FLAG_RESTART] = 1u; + gAgi->flags[FLAG_SOUND] = soundWasOn ? 1u : 0u; + jlSurfaceClear(jlStageGet(), AGI_COLOR_BLACK); } + // The next cycle starts v10 twentieths of a second after this one. + do { + agiIdle(); + now = jlMillisElapsed(); + } while (now - lastCycleMs < (uint32_t)gAgi->vars[VAR_CYCLE_DELAY] * CYCLE_UNIT_MS && !gAgi->quitRequested); + lastCycleMs = now; } - - jlSurfaceClear(gAgiStage, COLOR_MISSING); - if (gBackdrop != NULL) { - jlSurfaceClear(gBackdrop, COLOR_MISSING); - } - - if (vmReady) { - uint8_t pump; - - for (pump = 0u; pump < STARTUP_CYCLE_LIMIT; pump++) { - if (gPicReady) { - break; - } - runVmCycle(); - } - } - - { - uint32_t lastVmCycleMs = 0u; - uint32_t lastRenderMs = 0u; - - for (;;) { - jlInputPoll(); - if (jlKeyPressed(KEY_ESCAPE)) { - break; - } - gAnyKeyHit = false; - dispatchAllPressedKeys(); - - // When the command line is live (and no modal is up), typed - // characters build the input line instead of firing controllers. - if (gVm.acceptInput && !gVm.printModal.active) { - agiInputCapture(); - } - - // Pump the AGI tick generator EVERY frame, even with a - // print modal up. This drives music event playback AND - // the v11 seconds counter -- both keyed to the same - // AGI tick rate so script timing stays consistent with - // music tempo. Pausing this would freeze the AGI clock. - agiTickAdvance(); - - // If a print() modal is up, any key dismisses it and resumes - // the VM next cycle. Don't run the VM or update inputs while - // the modal is alive. - if (gVm.printModal.active) { - if (gAnyKeyHit) { - agiVmAckPrint(&gVm); - } - } else { - uint32_t nowMs = jlMillisElapsed(); - uint8_t delayJiffies; - uint32_t cyclePeriodMs; - - updateEgoFromInput(); - delayJiffies = gVm.vars[10]; - if (delayJiffies == 0u) { delayJiffies = 1u; } - cyclePeriodMs = (uint32_t)delayJiffies * (uint32_t)AGI_JIFFY_MS; - if (vmReady && (nowMs - lastVmCycleMs) >= cyclePeriodMs) { - runVmCycle(); - lastVmCycleMs = nowMs; - } - if (gVm.haltReason == AGI_VM_HALT_QUIT) { - break; - } - } - - // Wall-clock-paced render. Audio refill still runs every - // loop iteration so DMA half-buffers get serviced even - // when we're skipping the visual update. - { - uint32_t nowMs2 = jlMillisElapsed(); - if (nowMs2 - lastRenderMs >= AGI_RENDER_PERIOD_MS) { - renderFrame(gAgiStage); - lastRenderMs = nowMs2; - } else { - jlStagePresent(); - } - } - // Pump the SB DMA mixer. Refills any half-buffer the - // ISR flagged for refill since last call, which in turn - // pulls the next chunk from agiSynthFill (when music - // is armed) or libxmp + SFX overlay (when not). - jlAudioFrameTick(); -#if defined(JOEYLIB_PLATFORM_IIGS) - // The IIgs noise voice is an ~80 ms one-shot; re-assert - // held noise once per frame so it sustains (same model as - // the music sequencer's per-pump re-assert). - if (gSoundActive && gPsg[3].attenuation < 15u) { - psgNoiseApply(); - } -#endif - jlWaitVBL(); - } - } - - agiSoundReset(); - jlAudioShutdown(); - releaseLogicCache(); - releaseViewCache(); - jlFree(gViewCache); - gViewCache = NULL; - if (gBackdrop != NULL) { - jlSurfaceDestroy(gBackdrop); - } - if (picOk) { - agiPicFree(&gPic); - } - agiResClose(&gGame); + soundShutdown(); + gfxFree(); + agiResClose(&gAgi->game); + jlFree(gAgi); jlShutdown(); return 0; } diff --git a/agi.h b/agi.h index 0b5c99e..ba20aa8 100644 --- a/agi.h +++ b/agi.h @@ -1,12 +1,20 @@ -// JoeyLib AGI interpreter (Sierra Adventure Game Interpreter, v2). +// JoeyAGI: an interpreter for Sierra's Adventure Game Interpreter (AGI) v2 +// games, written from the public AGI specifications on JoeyLib. // -// Reimplements the engine that ran Sierra's first wave of graphic -// adventures (KQ1-3, SQ1-2, PQ1, LSL1, Manhunter 1-2) on top of -// JoeyLib's surface/draw/input APIs. v2 format only for Phase 0; v3 -// (LZW-compressed) lands in a later phase. -// -// This is a from-scratch implementation written against the public -// AGI format specification, not derived from any GPL'd reference. +// One game runs at a time, so the interpreter's state is a single AgiStateT +// (gAgi), allocated at start-up with jlAlloc to keep it out of the IIgs's +// bank-0 BSS. The modules: +// agiRes.c resource directories, VOL files, WORDS.TOK, OBJECT +// agiPic.c PICTURE decoding into the picture buffers +// agiView.c VIEW parsing +// agiGfx.c the picture buffers, screen objects' blitting, the stage +// agiObj.c screen objects: cycling, motion, collisions +// agiText.c text: display, windows, status and input lines, menus +// 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 +// agiVm.c the LOGIC interpreter +// agi.c start-up and the interpreter cycle #ifndef AGI_H #define AGI_H @@ -16,44 +24,185 @@ #include -// AGI resource directories are sized by the on-disk file length. The -// maximum index any single game references is well under 256 for the -// supported game set; oversize directories are rejected at load. -#define AGI_MAX_RESOURCES 256 +// ----- Limits and the game's geometry ----- -// AGI VOL.x files are numbered 0..15 (4-bit field in the directory -// entry). One open FILE * per volume kept resident for the life of -// the game session. -#define AGI_MAX_VOLUMES 16 +// Resource numbers are one byte; directories may name all 256. +#define AGI_MAX_RESOURCES 256u +// VOL.x files: the directory's volume nibble. +#define AGI_MAX_VOLUMES 16u -// AGI's native rendering surface. Visual and priority screens are -// each 160 wide; horizontal pixel doubling produces the 320-wide -// display. The vertical 168 leaves room above and below for the -// status line and command prompt in a 200-line display. -#define AGI_PIC_WIDTH 160 -#define AGI_PIC_HEIGHT 168 -#define AGI_PIC_PIXELS (AGI_PIC_WIDTH * AGI_PIC_HEIGHT) +// The picture is 160x168 AGI pixels; each is two screen pixels wide, so +// it fills 320x168 of the 320x200 screen, below the status line. +#define AGI_WIDTH 160 +#define AGI_HEIGHT 168 +#define AGI_PIXELS ((uint16_t)AGI_WIDTH * (uint16_t)AGI_HEIGHT) +#define AGI_MAX_X (AGI_WIDTH - 1) +#define AGI_MAX_Y (AGI_HEIGHT - 1) +#define AGI_SCREEN_PX_PER_AGI 2 -// Flood-fill spread tests: a fill spreads through pixels matching the -// background color and stops at anything else. Visual default bg is -// 15 (white), priority default bg is 4 (red). -#define AGI_VISUAL_BG 15u -#define AGI_PRIORITY_BG 4u +// The 40x25 text grid of 8x8 character cells. +#define AGI_TEXT_COLS 40u +#define AGI_TEXT_ROWS 25u +#define AGI_CELL_PX 8 -// AGI 16-color CGA-derived palette in JoeyLib's $0RGB format (4-bit -// per channel). Entry 6 keeps the classic CGA "brown" tweak (R=A, -// G=5, B=0) so the muddy-yellow-on-CGA-monitors look matches the -// original render rather than reading as pure yellow-green. -extern const uint16_t kAgiPalette[16]; +// Colours. The picture starts white with priority 4 (red) everywhere. +#define AGI_COLOR_BLACK 0u +#define AGI_COLOR_RED 4u +#define AGI_COLOR_WHITE 15u +#define AGI_COLOR_COUNT 16u +#define AGI_COLOR_MASK 0x0Fu +#define AGI_PIC_BG_COLOR AGI_COLOR_WHITE +#define AGI_PIC_BG_PRIORITY 4u -// Resource directory entry: which VOL.x file holds the resource and -// where in that file it starts. A 0xFF marker in volume means the -// directory slot is empty (game does not define this resource ID). -typedef struct { - uint8_t volume; - uint32_t offset; -} AgiResEntryT; +// Priority values below 4 are control lines drawn into the priority +// screen: an unconditional barrier, a conditional barrier (blocks), a +// signal (alarm) line and water. +#define AGI_CTL_BARRIER 0u +#define AGI_CTL_BLOCK 1u +#define AGI_CTL_SIGNAL 2u +#define AGI_CTL_WATER 3u +#define AGI_PRIORITY_MIN 4u +#define AGI_PRIORITY_MAX 15u +// Priority bands: y below 48 is priority 4, then a band every 12 lines +// from priority 5 at y 48 to 14 at y 156..167. +#define AGI_PRI_BAND_TOP 48 +#define AGI_PRI_BAND_HEIGHT 12 +#define AGI_PRI_FIRST_BAND 5u + +// The horizon new.room restores. +#define AGI_DEFAULT_HORIZON 36u + +// Animated (screen) objects: OBJECT's header says how many a game uses. +#define AGI_MAX_OBJECTS 32u +#define AGI_EGO 0u + +// Inventory items and the 40-character strings. +#define AGI_MAX_ITEMS 256u +#define AGI_ITEM_CARRIED 255u +#define AGI_MAX_STRINGS 24u +#define AGI_STRING_LEN 40u + +// Variables, flags, controllers (set.key and menu items share one space). +#define AGI_NUM_VARS 256u +#define AGI_NUM_FLAGS 256u +#define AGI_NUM_CONTROLLERS 256u +#define AGI_MAX_KEYMAPS 64u +#define AGI_SCRIPT_MAX 200u +#define AGI_SCRIPT_ARGS 7u + +// Menus. +#define AGI_MAX_MENUS 10u +#define AGI_MAX_MENU_ITEMS 48u +#define AGI_MENU_NAME_MAX 20u +#define AGI_MENU_ITEM_MAX 38u + +// The command line and the parser. +#define AGI_INPUT_MAX 40u +#define AGI_MAX_WORDS 12u +#define AGI_WORD_MAX_LEN 40u +#define AGI_WORD_IGNORE 0u +#define AGI_WORD_ANY 1u +#define AGI_WORD_REST 9999u +#define AGI_WORD_NONE 0xFFFFu +#define AGI_WORDS_HEADER_BYTES 52u +#define AGI_WORDS_MAX 16384u + + +// Key codes as AGI sees them: the character, or the PC scan code in the +// high byte. Joystick buttons are 0x0101 and 0x0201. +#define AGI_KEY_NONE 0u +#define AGI_KEY_BACKSPACE 0x0008u +#define AGI_KEY_ENTER 0x000Du +#define AGI_KEY_ESCAPE 0x001Bu +#define AGI_KEY_UP 0x4800u +#define AGI_KEY_DOWN 0x5000u +#define AGI_KEY_LEFT 0x4B00u +#define AGI_KEY_RIGHT 0x4D00u +#define AGI_KEY_JOY_BUTTON_1 0x0101u +#define AGI_KEY_JOY_BUTTON_2 0x0201u + +// A menu.input or inventory answer meaning "nothing chosen". +#define AGI_NO_CONTROLLER 0xFFu +// inputGetString with no position: on the input line. +#define AGI_NO_POSITION 0xFFu +// No set.cursor.char yet: the input line shows no cursor. +#define AGI_NO_CURSOR '\0' + +// The font (agiFont.c): characters 0x20..0x7E, 8 rows each. +#define AGI_FONT_FIRST 0x20u +#define AGI_FONT_GLYPHS 95u +#define AGI_FONT_ROWS 8u +extern const uint8_t kAgiFont[AGI_FONT_GLYPHS][AGI_FONT_ROWS]; + + +// ----- Reserved variables and flags (AGI specification 3.3 and 3.4) ----- + +#define VAR_ROOM 0u +#define VAR_PREV_ROOM 1u +#define VAR_EGO_EDGE 2u +#define VAR_SCORE 3u +#define VAR_OBJ_EDGE_OBJ 4u +#define VAR_OBJ_EDGE 5u +#define VAR_EGO_DIR 6u +#define VAR_MAX_SCORE 7u +#define VAR_FREE_PAGES 8u +#define VAR_BAD_WORD 9u +#define VAR_CYCLE_DELAY 10u +#define VAR_SECONDS 11u +#define VAR_MINUTES 12u +#define VAR_HOURS 13u +#define VAR_DAYS 14u +#define VAR_EGO_VIEW 16u +#define VAR_KEY 19u +#define VAR_COMPUTER 20u +#define VAR_WINDOW_TIMER 21u +#define VAR_SOUND_TYPE 22u +#define VAR_INPUT_MAX 24u +#define VAR_SELECTED_ITEM 25u +#define VAR_MONITOR 26u + +#define FLAG_EGO_WATER 0u +#define FLAG_ENTERED 2u +#define FLAG_EGO_SIGNAL 3u +#define FLAG_SAID_OK 4u +#define FLAG_NEW_ROOM 5u +#define FLAG_RESTART 6u +#define FLAG_NO_SCRIPT 7u +#define FLAG_SOUND 9u +#define FLAG_FIRST_LOGIC0 11u +#define FLAG_RESTORED 12u +#define FLAG_ITEM_SELECT 13u +#define FLAG_MENU 14u +#define FLAG_WINDOW_STAYS 15u + + +// ----- Interpreter versions ----- + +// 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. +#define AGI_VERSION_MEASURED 2435u + +// Behaviour of particular Sierra interpreter versions that games may lean +// on; agiQuirk says whether the game's version has one (the version ranges +// are agiRes.c's kQuirkVersions). +typedef enum { + // A window's x is a byte and the screen one line of 160-pixel rows: + // a box starting left of the screen starts 256 pixels on instead, + // and every span runs on into the next row. + AGI_QUIRK_WINDOW_BYTE_X = 0, + // A typed character first steps back over the cursor, whether or not + // one was drawn there. + AGI_QUIRK_ECHO_STEPS_BACK = 1, + // A picture line carries its error in a byte, losing steps once it + // passes 255. + AGI_QUIRK_LINE_BYTE_ERROR = 2, + AGI_QUIRK_COUNT = 3 +} AgiQuirkE; + + +// ----- Resources (agiRes.c) ----- typedef enum { AGI_RES_LOGIC = 0, @@ -63,544 +212,380 @@ typedef enum { AGI_RES_COUNT = 4 } AgiResTypeE; +typedef struct { + uint8_t volume; + uint8_t offsetHigh; + uint16_t offsetLow; +} AgiResEntryT; -// Open-game state: directory metadata + handles to every VOL.x file. -// Loading and freeing individual resource blobs is per-call so the -// working-set footprint stays small enough for stock IIgs / Amiga. typedef struct { FILE *volFiles[AGI_MAX_VOLUMES]; uint16_t resCount[AGI_RES_COUNT]; AgiResEntryT resDir[AGI_RES_COUNT][AGI_MAX_RESOURCES]; - // WORDS.TOK dictionary, loaded whole. Prefix-compressed entries are - // walked on demand by agiWordId (parse is infrequent -- once per typed - // command -- so a flat scan beats building a hashed table). uint8_t *words; - uint32_t wordsLen; + uint16_t wordsLen; + uint8_t *objectData; + uint16_t itemCount; + uint8_t maxObjects; + uint16_t interpreterVersion; } AgiGameT; -// Reserved AGI word group IDs. -#define AGI_WORD_IGNORE 0u // "a", "the", ... -- dropped by parse -#define AGI_WORD_ANYWORD 1u // said() wildcard: matches any one word -#define AGI_WORD_REST 9999u // said() wildcard: matches rest of line -#define AGI_WORD_UNKNOWN 0xFFFFu // agiWordId: typed word not in dictionary -// WORDS.TOK layout + limits. The header is 26 big-endian 2-byte offsets -// (one per initial letter); word entries follow. AGI v2 dictionaries are a -// few KB (KQ3 is ~5.7 KB); 16 KB is ample headroom. -#define AGI_WORDS_HEADER_BYTES 52u -#define AGI_WORDS_MAX 16384u -#define AGI_WORD_MAX_LEN 40u - -// Command-line parser limits + the reserved flag/var the interpreter uses -// to talk to logic scripts about input: -// flag 2 -- set when the player has entered a command this cycle; said() -// matches only while it is set and clears it on a match so only -// the first matching said() in the logic scan fires. -// var 9 -- 1-based position of a typed word that isn't in the dictionary. -#define AGI_INPUT_MAX 40u // chars in the on-screen command line -#define AGI_MAX_PARSED 12u // word IDs kept from one command -#define AGI_INPUT_ROW 24u // bottom text row shows the command line -#define AGI_FLAG_INPUT 2u -#define AGI_VAR_UNKNOWN_WORD 9u - - -// Decoded PIC working buffers. Both planes are 160x168 chunky 8bpp; -// only the low nibble (4 bits) of each byte carries actual color -// information. Buffers are heap-allocated so the binary's BSS -// footprint stays under what the IIgs stock heap can satisfy in -// one shot. -typedef struct { - uint8_t *visual; - uint8_t *priority; -} AgiPicT; - - -// VIEW resources hold one or more animation loops; each loop is a -// sequence of cels (frames). The parser walks the published format -// once and records pointers into the raw resource buffer so per-cel -// access is O(1) without re-parsing. -#define AGI_MAX_LOOPS_PER_VIEW 16u -#define AGI_MAX_CELS_PER_LOOP 32u +// ----- Pictures and views ----- typedef struct { uint8_t width; uint8_t height; - uint8_t transparentColor; - uint8_t mirrored; - uint8_t mirrorSourceLoop; - const uint8_t *rleData; -} AgiCelInfoT; + uint8_t transparent; + uint8_t mirrorFlags; + const uint8_t *rle; +} AgiCelT; typedef struct { - uint8_t celCount; - AgiCelInfoT cels[AGI_MAX_CELS_PER_LOOP]; -} AgiLoopInfoT; + uint8_t celCount; + AgiCelT *cels; +} AgiLoopT; typedef struct { - uint8_t *raw; - uint16_t rawLength; - uint8_t loopCount; - AgiLoopInfoT loops[AGI_MAX_LOOPS_PER_VIEW]; + uint8_t *raw; + uint16_t rawLength; + uint8_t loopCount; + AgiLoopT *loops; + const char *description; } AgiViewT; -// AGI priority for an actor based on its feet (bottom-edge) Y. -// 0..47 is the sky band (priority 4); 48..167 is ground divided into -// 10 bands at priority 5..14 (12 pixels each). Priority 15 is "always -// on top" and isn't reached by the actor's natural Y. -uint8_t agiActorPriorityForY(int16_t y); - - -// LOGIC VM state. AGI's interpreter is a stack-of-logics bytecode -// VM with 256 byte-sized variables and 256 single-bit flags. -#define AGI_VM_NUM_VARS 256u -#define AGI_VM_NUM_FLAGS 256u +// ----- Screen objects ----- typedef enum { - AGI_VM_HALT_NONE = 0, - AGI_VM_HALT_RETURN = 1, - AGI_VM_HALT_UNKNOWN_OP = 2, - AGI_VM_HALT_TRUNCATED = 3, - AGI_VM_HALT_NEW_ROOM = 4, - AGI_VM_HALT_NO_LOGIC = 5, - AGI_VM_HALT_STACK_OVER = 6, - AGI_VM_HALT_PRINT_PENDING = 7, // print() popup waiting for ack - AGI_VM_HALT_QUIT = 8 // game logic asked to exit -} AgiVmHaltE; - -#define AGI_VM_CALL_STACK_MAX 8u - -typedef struct { - const uint8_t *code; - uint16_t codeLength; - uint16_t pc; - uint8_t logicId; -} AgiVmFrameT; - - -// ----- Animated objects ----- -// -// AGI v2 supports up to 16 simultaneous animated objects ("screen -// objects"). Object 0 is the player/ego; the rest are NPCs, props, -// and incidental animations driven by the game logic. The state -// here mirrors the per-object fields the game scripts manipulate -// via animate.obj / set.view / set.cel / position / set.dir / etc. -#define AGI_MAX_OBJECTS 16u - -#define AGI_OBJ_FLAG_ANIMATED 0x01u // animate.obj called -#define AGI_OBJ_FLAG_DRAWN 0x02u // draw() called, sprite is on-screen -#define AGI_OBJ_FLAG_CYCLING 0x04u // start.cycling, advances cel -#define AGI_OBJ_FLAG_UPDATING 0x08u // start.update, render allowed -#define AGI_OBJ_FLAG_LOOP_FIXED 0x10u // fix.loop disables auto-loop choice -#define AGI_OBJ_FLAG_PRI_FIXED 0x20u // set.priority overrides Y-band priority -#define AGI_OBJ_FLAG_HORIZON_IGN 0x40u // ignore.horizon -#define AGI_OBJ_FLAG_OBJS_IGN 0x80u // ignore.objs (collision) - -typedef enum { - AGI_CYCLE_NORMAL = 0, // 0->1->2..->last->0->... - AGI_CYCLE_END_LOOP = 1, // play once forward, stop, set flag - AGI_CYCLE_REVERSE = 2, // last->..->1->0->last->... - AGI_CYCLE_REV_LOOP = 3 // play once backward, stop, set flag + AGI_CYCLE_NORMAL = 0, + AGI_CYCLE_END_LOOP = 1, + AGI_CYCLE_REVERSE_LOOP = 2, + AGI_CYCLE_REVERSE = 3 } AgiCycleE; typedef enum { - AGI_MOTION_NORMAL = 0, // moves on direction (vN+6 for ego) - AGI_MOTION_WANDER = 1, // random direction every few cycles - AGI_MOTION_FOLLOW_EGO = 2, // chases obj 0 - AGI_MOTION_MOVE_OBJ = 3 // moving toward fixed (x,y) + AGI_MOTION_NORMAL = 0, + AGI_MOTION_WANDER = 1, + AGI_MOTION_FOLLOW = 2, + AGI_MOTION_MOVE = 3 } AgiMotionE; +// Directions: 0 is still, 1..8 run clockwise from north. +#define AGI_DIR_NONE 0u +#define AGI_DIR_N 1u +#define AGI_DIR_NE 2u +#define AGI_DIR_E 3u +#define AGI_DIR_SE 4u +#define AGI_DIR_S 5u +#define AGI_DIR_SW 6u +#define AGI_DIR_W 7u +#define AGI_DIR_NW 8u +#define AGI_DIR_COUNT 9u + +// Edges, as v2 and v5 report them. +#define AGI_EDGE_NONE 0u +#define AGI_EDGE_TOP 1u +#define AGI_EDGE_RIGHT 2u +#define AGI_EDGE_BOTTOM 3u +#define AGI_EDGE_LEFT 4u + +#define OBJ_ANIMATED 0x0001u +#define OBJ_DRAWN 0x0002u +#define OBJ_UPDATE 0x0004u +#define OBJ_CYCLING 0x0008u +#define OBJ_FIX_LOOP 0x0010u +#define OBJ_FIX_PRIORITY 0x0020u +#define OBJ_IGNORE_HORIZON 0x0040u +#define OBJ_IGNORE_BLOCKS 0x0080u +#define OBJ_IGNORE_OBJS 0x0100u +#define OBJ_ON_WATER 0x0200u +#define OBJ_ON_LAND 0x0400u +// Measured from Sierra's interpreter: an object's first step after the +// game starts or the room changes is lost, as is the first cel change +// after end.of.loop or reverse.loop on an object already drawn. +#define OBJ_SKIP_STEP 0x0800u +#define OBJ_SKIP_CYCLE 0x1000u + typedef struct { - uint8_t viewId; - uint8_t loop; - uint8_t cel; - uint8_t priority; // 4..15; high nibble masking - int16_t x; // baseline (bottom-left) AGI x - int16_t y; // baseline (bottom) AGI y - int16_t prevX; - int16_t prevY; - uint8_t prevLoop; - uint8_t prevCel; - uint8_t prevView; - uint8_t direction; // 0=stop, 1..8 (up=1, clockwise) - uint8_t stepSize; // pixels per step - uint8_t stepTime; // VM cycles between steps - uint8_t stepTick; // counter toward stepTime - uint8_t cycleTime; // VM cycles between cel advances - uint8_t cycleTick; // counter toward cycleTime - AgiCycleE cycleMode; - uint8_t endOfLoopFlag; // flag id to set when end.of.loop fires - AgiMotionE motionType; - int16_t targetX; // for move.obj - int16_t targetY; - uint8_t moveStepBackup; // step size before move.obj started - uint8_t moveDoneFlag; // flag id set on move.obj completion - uint8_t wanderTick; // wander direction-change countdown - uint8_t followStep; // step.size override for follow.ego - uint8_t followDoneFlag; - uint8_t flags; // AGI_OBJ_FLAG_* + const AgiViewT *view; + uint8_t viewId; + uint8_t loop; + uint8_t cel; + uint8_t width; + uint8_t height; + uint8_t priority; + int16_t x; + int16_t y; + int16_t prevX; + int16_t prevY; + uint16_t flags; + uint8_t direction; + uint8_t stepSize; + uint8_t stepTime; + uint8_t stepCount; + uint8_t cycleTime; + 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; + // Blit state: where the object was last drawn and the buffer bytes + // its cel covered there, restored when it is erased. + bool blitted; + int16_t blitX; + int16_t blitY; + uint8_t blitW; + uint8_t blitH; + uint8_t *saveUnder; } AgiObjectT; -// ----- Text plane / status line ----- -// -// AGI's text plane is 40 columns x 25 rows of 8x8 character cells. -// Status line (row 0) is reserved for game-managed status text; -// display() writes anywhere by row/col; print() pops a modal window. -#define AGI_TEXT_COLS 40u -#define AGI_TEXT_ROWS 25u +// ----- Text, menus, the parser ----- typedef struct { - char text[AGI_TEXT_COLS + 1u]; // NUL-terminated - uint8_t fg; - uint8_t bg; -} AgiTextLineT; - -#define AGI_PRINT_MAX_LEN 240u - -typedef struct { - bool active; // print modal on screen - uint16_t length; - char message[AGI_PRINT_MAX_LEN + 1u]; -} AgiPrintModalT; - - -// ----- Controllers ----- -// -// set.key binds a (key1, key2) pair to a controller id 0..49. When -// the host dispatches a key matching a binding, the controller's -// "fired this cycle" bit is set. The IF test controller(N) consumes -// the bit so the binding only fires once per press. -#define AGI_MAX_CONTROLLERS 50u -#define AGI_MAX_KEY_BINDINGS 64u - -typedef struct { - uint8_t key1; // ASCII or extended scancode (low byte) - uint8_t key2; // extended high byte (0 for plain ASCII) + uint16_t key; uint8_t controller; - uint8_t active; -} AgiKeyBindingT; +} AgiKeyMapT; - -// ----- Menus ----- -// -// set.menu / set.menu.item populate a 2D menu from message ids; the -// game can later disable individual items. menu.input pops it up; -// the user's choice fires the corresponding controller. Stored as -// state so menu.input can render and return a controller id. -#define AGI_MAX_MENUS 16u -#define AGI_MAX_MENU_ITEMS 16u +// One recorded picture-building command (the restore script): an AGI +// command number and its argument values (add.to.pic has seven). +typedef struct { + uint8_t op; + uint8_t args[AGI_SCRIPT_ARGS]; +} AgiScriptEntryT; typedef struct { - uint8_t messageId; // logic 0 message id - uint8_t controller; // fired when item selected - uint8_t enabled; + char text[AGI_MENU_ITEM_MAX + 1u]; + uint8_t controller; + bool enabled; } AgiMenuItemT; typedef struct { - uint8_t nameMsgId; - uint8_t itemCount; - AgiMenuItemT items[AGI_MAX_MENU_ITEMS]; + char name[AGI_MENU_NAME_MAX + 1u]; + uint8_t firstItem; + uint8_t itemCount; + uint8_t col; } AgiMenuT; -// ----- Strings ----- -// -// set.string / get.string / word.to.string maintain a small pool of -// up to 24 game-defined strings. Each is 40 chars max (one screen row). -#define AGI_MAX_STRINGS 24u -#define AGI_STRING_LEN 40u - - -// Host callbacks invoked from the VM for side-effecting opcodes the -// host owns. The host owns the logic-resource cache and screen -// surfaces; the VM never touches either directly. fetchLogic returns -// the post-header bytecode pointer and its length (the 2-byte LE -// logic header has already been consumed by the host). NULL on -// missing logic causes the VM to halt with AGI_VM_HALT_NO_LOGIC. -// -// haveKey returns true if a keystroke is buffered, consuming it so -// the next call returns false until another key arrives. Used by -// the AGI test command have.key. -// -// fetchMessage returns the *decrypted* bytes of message `msgId` -// from logic `logicId`, or NULL if the message is missing. The -// returned pointer is valid until the next fetchMessage call (host -// may use a single shared scratch buffer). Used by display, print, -// print.at and friends. -typedef struct { - const uint8_t *(*fetchLogic)(void *ctx, uint8_t logicId, uint16_t *outBytecodeLength); - void (*loadPic)(void *ctx, uint8_t picId); - void (*drawPic)(void *ctx, uint8_t picId); - void (*showPic)(void *ctx); - void (*discardPic)(void *ctx, uint8_t picId); - void (*overlayPic)(void *ctx, uint8_t picId); - bool (*haveKey)(void *ctx); - const char *(*fetchMessage)(void *ctx, uint8_t logicId, uint8_t msgId); - void (*addToPic)(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel, - uint8_t x, uint8_t y, uint8_t pri, uint8_t margin); - // Return the playback duration of sound `soundId` in milliseconds, - // or 0 if the sound is missing/empty. Used only as a fallback - // duration cap when the host can't reliably report playback end; - // when isPlayingSound is wired, the VM ignores this and trusts - // the host's live state instead. - uint32_t (*soundDuration)(void *ctx, uint8_t soundId); - // Start playback of sound `soundId`. Any currently-playing AGI - // sound is replaced. The VM polls isPlayingSound each tick and - // sets the sound-done flag on the true->false transition. - void (*playSound)(void *ctx, uint8_t soundId); - // Stop playback immediately. Called by OP_STOP_SOUND (and any - // path that preempts the current sound). - void (*stopSound)(void *ctx); - // Live host-side playback state. Returns true while audio output - // is active for the currently-armed sound, false once the host - // has run all SND events to completion (or stopSound was called). - // Source of truth for sound completion: matches what the user - // actually hears, so the title sequence advances exactly when - // the music ends. - bool (*isPlayingSound)(void *ctx); - // View metadata for accurate cycling. AGI's cycle/loop opcodes - // (cycle.normal, end.of.loop, last.cel, number.of.loops...) need - // to know the cel count of the current view+loop and the loop - // count of the current view. Without these, end.of.loop fires - // its flag only at the AGI_MAX_CELS_PER_LOOP boundary -- and a - // sparkle that visually has 8 cels but is treated as 32 won't - // signal its end until 4x the intended time, breaking title - // sequences that arm end.of.loop expecting a tight cycle. - // - // Returns 0 if the view/loop is unloaded; callers treat 0 as - // "still running" so a not-yet-loaded view doesn't terminate - // early. Returns the loop count (>=1) or cel count (>=1) when - // the view is available. - uint8_t (*viewLoopCount)(void *ctx, uint8_t viewId); - uint8_t (*viewCelCount)(void *ctx, uint8_t viewId, uint8_t loopId); - void *ctx; -} AgiVmCallbacksT; +// ----- The interpreter state ----- typedef struct { - // ----- core state ----- - uint8_t vars[AGI_VM_NUM_VARS]; - uint8_t flags[AGI_VM_NUM_FLAGS]; - const uint8_t *code; - uint16_t codeLength; - uint16_t pc; - AgiVmHaltE haltReason; - uint8_t lastUnknownOp; - uint8_t newRoomId; - uint8_t currentLogicId; - uint8_t callDepth; - AgiVmFrameT callStack[AGI_VM_CALL_STACK_MAX]; - AgiVmCallbacksT callbacks; + AgiGameT game; - // ----- objects ----- - AgiObjectT objects[AGI_MAX_OBJECTS]; + uint8_t vars[AGI_NUM_VARS]; + uint8_t flags[AGI_NUM_FLAGS]; + char strings[AGI_MAX_STRINGS][AGI_STRING_LEN + 1u]; + uint8_t controllers[AGI_NUM_CONTROLLERS]; + AgiKeyMapT keyMaps[AGI_MAX_KEYMAPS]; + uint8_t keyMapCount; - // ----- text plane ----- - AgiTextLineT textRows[AGI_TEXT_ROWS]; - uint8_t textFg; - uint8_t textBg; - bool statusLineOn; - uint8_t statusLineRow; // usually 0 - uint8_t horizon; // any obj y < horizon stops - bool programControl; // true => program controls ego (false = player) - bool acceptInput; // accept.input; toggled by accept/prevent - AgiPrintModalT printModal; + // The commands that built the current picture, replayed by restore. + AgiScriptEntryT script[AGI_SCRIPT_MAX]; + uint8_t scriptCount; - // ----- controllers + key bindings ----- - AgiKeyBindingT keyBindings[AGI_MAX_KEY_BINDINGS]; - uint8_t controllerFired[AGI_MAX_CONTROLLERS]; + // Logic resources (loaded on demand) and each one's scan start. + uint8_t *logicRaw[AGI_MAX_RESOURCES]; + uint16_t logicLength[AGI_MAX_RESOURCES]; + uint16_t logicScanStart[AGI_MAX_RESOURCES]; + bool logicLoaded[AGI_MAX_RESOURCES]; + uint8_t currentLogic; + bool abortLogic; + bool newRoomPending; + uint8_t newRoomNumber; - // ----- menus ----- - AgiMenuT menus[AGI_MAX_MENUS]; - uint8_t menuCount; - uint8_t menuOpen; // 0xFF = not open + // Views loaded with load.view or on demand. + AgiViewT *views[AGI_MAX_RESOURCES]; - // ----- strings ----- - char strings[AGI_MAX_STRINGS][AGI_STRING_LEN + 1u]; + // Screen objects. + AgiObjectT objects[AGI_MAX_OBJECTS]; + uint8_t objectCount; + uint8_t horizon; + bool blockActive; + uint8_t blockX1; + uint8_t blockY1; + uint8_t blockX2; + uint8_t blockY2; + bool playerControl; - // ----- parser / command input ----- - // inputLine is the raw text the player is typing; on Enter agi.c - // tokenizes it against the dictionary into parsedWords[] (group IDs, - // ignore-words dropped) and sets flag 2 for said() to test. - char inputLine[AGI_INPUT_MAX + 1u]; - uint8_t inputLen; - uint16_t parsedWords[AGI_MAX_PARSED]; - uint8_t parsedWordCount; + // Inventory: the room each item is in. + uint8_t itemRoom[AGI_MAX_ITEMS]; - // ----- sound playback state ----- - // The VM polls callbacks.isPlayingSound() each tick; when it - // transitions from true to false, the sound-done flags fire. - // soundPlaying tracks whether we are currently waiting on an - // armed sound; soundDoneFlag is the per-sound user flag from - // OP_SOUND's second arg (0 = no user flag, just engine flag 9). - bool soundPlaying; - uint8_t soundDoneFlag; -} AgiVmT; + // Screen layout (configure.screen) and text state. + uint8_t picRow; + uint8_t inputRow; + uint8_t statusRow; + bool statusOn; + bool inputOn; + bool textMode; + uint8_t textFg; + uint8_t textBg; + char cursorChar; + bool windowOpen; + + // The command line. + char inputLine[AGI_INPUT_MAX + 1u]; + uint8_t inputLen; + char lastLine[AGI_INPUT_MAX + 1u]; + uint16_t wordIds[AGI_MAX_WORDS]; + char wordText[AGI_MAX_WORDS][AGI_WORD_MAX_LEN + 1u]; + uint8_t wordCount; + + // Menus. + AgiMenuT menus[AGI_MAX_MENUS]; + AgiMenuItemT menuItems[AGI_MAX_MENU_ITEMS]; + uint8_t menuCount; + uint8_t menuItemCount; + bool menuSubmitted; + bool menuRequested; + uint8_t menuLastMenu; + + // Requests that end the cycle, and a restore that just did. + bool quitRequested; + bool restartRequested; + bool restored; +} AgiStateT; + +extern AgiStateT *gAgi; -// ----- Resource loader (agiRes.c) ----- +// ----- agiRes.c ----- -// Close all volume files; safe to call after a failed open. -void agiResClose(AgiGameT *game); - -// Allocate and return a resource's raw bytes. Caller frees with -// free(). Returns NULL on any failure (slot empty, signature bad, -// I/O error, out of memory). On success *outLength receives the -// resource payload length in bytes. -uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength); - -// Open the AGI v2 game whose LOGDIR/PICDIR/VIEWDIR/SNDDIR and VOL.0..VOL.N -// files are staged under DATA/ (opened by bare name via jlDataOpen). -// Returns true if every directory file and at least one VOL.* file opened. -// Partial-success on missing optional volumes; agiResLoad reports the -// missing-volume case per resource. -bool agiResOpen(AgiGameT *game); - -// Look up a word in the loaded WORDS.TOK dictionary (case-insensitive) and -// return its AGI group ID, or AGI_WORD_UNKNOWN if the word is absent (or no -// dictionary loaded). Synonyms share a group ID; group 0 = ignore-words. -uint16_t agiWordId(const AgiGameT *game, const char *word); +void agiDecrypt(uint8_t *data, uint16_t length); +const char *agiItemName(const AgiGameT *game, uint8_t item); +uint8_t agiItemStartRoom(const AgiGameT *game, uint8_t item); +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); +uint16_t agiWordMatch(const AgiGameT *game, const char *text, uint8_t *outLen); -// ----- Picture decoder (agiPic.c) ----- +// ----- agiPic.c ----- -// Allocate the two 160x168 working buffers. Caller must agiPicFree -// before exit. Returns false if either malloc fails. -bool agiPicAlloc(AgiPicT *pic); - -// Pixel-doubled blit of the visual plane onto the stage starting at -// (0, destY). 160 source columns become 320 stage columns; the rows -// copy 1:1 so the output occupies (0, destY) - (319, destY+167). -void agiPicBlit(const AgiPicT *pic, jlSurfaceT *stage, int16_t destY); - -// Reset both planes to their AGI default backgrounds (15 / 4). -void agiPicClear(AgiPicT *pic); - -// Decode an AGI v2 PIC resource into the working buffers. The buffers -// must already be cleared (agiPicClear) and allocated. Returns false -// if the byte stream is malformed (truncated argument, unknown -// opcode); on failure the buffers are left in a partial state. -bool agiPicDecode(AgiPicT *pic, const uint8_t *data, uint16_t length); - -void agiPicFree(AgiPicT *pic); +bool agiPicDecode(uint8_t *visual, uint8_t *priority, const uint8_t *data, uint16_t length); +uint8_t agiPriorityForY(int16_t y); +int16_t agiYForPriority(uint8_t priority); -// ----- View decoder (agiView.c) ----- +// ----- agiView.c ----- -// Parse an AGI VIEW resource. Takes ownership of `rawBytes` -- they -// must remain valid until agiViewFree (the view's cel pointers index -// into them). Returns false on malformed data; on failure the buffer -// is freed and `view` is left zeroed. -bool agiViewParse(AgiViewT *view, uint8_t *rawBytes, uint16_t length); - -void agiViewFree(AgiViewT *view); - -// Draw cel (loopIdx, celIdx) of `view` at AGI coordinate (x, y), -// where (x, y) is the bottom-left corner of the cel in 160x168 AGI -// space. Pixels are masked per-pixel by the priority plane: actor -// pixels are skipped where the picture priority exceeds actorPri. -// The blit is pixel-doubled horizontally onto the stage at -// (0, destY) -- pass the same destY used by agiPicBlit. -void agiViewDraw(const AgiViewT *view, uint8_t loopIdx, uint8_t celIdx, - int16_t x, int16_t y, uint8_t actorPri, - const uint8_t *picPriority, - jlSurfaceT *stage, int16_t destY); +const AgiCelT *agiViewCel(const AgiViewT *view, uint8_t loop, uint8_t cel, bool *outMirror); +void agiViewDiscardUnused(void); +const AgiViewT *agiViewGet(uint8_t viewId); -// ----- LOGIC VM (agiVm.c) ----- +// ----- agiGfx.c ----- -// Reset every variable and flag to 0 and clear the halt state. -void agiVmInit(AgiVmT *vm); - -// Point the VM at a logic resource's bytecode segment. AGI v2 logic -// resources start with a 2-byte little-endian length for the bytecode -// section followed by the bytecode itself (messages live past it). -// Returns false if the buffer is too short to contain a header. -bool agiVmLoadLogic(AgiVmT *vm, const uint8_t *resourceBytes, uint16_t resourceLength); - -// Reset the VM's program counter to 0 against an already-loaded -// bytecode pointer. Used by the host after handling a halt (room -// transition, frame end) to re-run a logic from the top without -// re-parsing its resource header. -void agiVmResetToLogic(AgiVmT *vm, const uint8_t *bytecode, uint16_t bytecodeLength, uint8_t logicId); - -// Run until the VM hits any halt condition (return, unknown opcode, -// truncated stream, or pending room transition). Returns the halt -// reason; details live in vm->lastUnknownOp / vm->newRoomId / vm->pc. -AgiVmHaltE agiVmRun(AgiVmT *vm); - -// Install host callbacks. May be called before or after agiVmInit; -// pass NULL for any callback the host doesn't implement (the VM will -// treat that opcode as a no-op stub). -void agiVmSetCallbacks(AgiVmT *vm, const AgiVmCallbacksT *callbacks); - -// Advance the AGI engine clock by `seconds` wall-clock seconds. Bumps -// v11 (seconds 0..59), v12 (minutes 0..59) and v13 (hours 0..23) with -// proper roll-over. Game logic uses these to detect per-second ticks -// (KQ3's logic.0 fires a per-second update branch when v11 changes, -// which sets flag 45 -- the gate the title countdown is waiting on). -// Safe to call with seconds=0 (no-op) and with any size up to UINT8_MAX. -void agiVmTickSeconds(AgiVmT *vm, uint8_t seconds); - -// Per-VM-cycle ticker. Called by the host once per game-loop cycle -// (~6 Hz, the AGI interpreter cadence). Advances every animated -// object's cel cycling and motion. Honors per-object stop.cycling / -// stop.motion / stop.update flags. Sets end.of.loop / move.obj.done -// flags as those events fire. -void agiVmTickAnimation(AgiVmT *vm); - -// Notify the VM that the host received key `keyCode` (low 7 bits = -// ASCII; high byte = extended scancode for arrow keys / Fn). Walks -// the key-binding table; if `keyCode` matches a set.key binding, -// the corresponding controller fires. Also dispatches to any open -// menu navigation. Called by the host once per pressed key. -void agiVmDispatchKey(AgiVmT *vm, uint8_t key1, uint8_t key2); - -// Acknowledge an open print() modal so the VM can resume. Called by -// the host when the user presses ENTER / SPACE / etc. while -// vm->printModal.active is true. Clears the modal and the -// AGI_VM_HALT_PRINT_PENDING halt reason. -void agiVmAckPrint(AgiVmT *vm); +void gfxAddToPic(uint8_t viewId, uint8_t loop, uint8_t cel, int16_t x, int16_t y, uint8_t priority, uint8_t margin); +uint8_t gfxControlAt(int16_t x, int16_t y); +void gfxDrawPic(uint8_t picId, bool clearFirst); +void gfxDrawStatic(void); +void gfxEraseAll(void); +void gfxEraseUpdating(void); +void gfxFree(void); +bool gfxInit(void); +void gfxPresentDirty(void); +void gfxRedrawAll(void); +void gfxRedrawUpdating(void); +void gfxRestorePicture(void); +void gfxShowObjectCel(uint8_t viewId); +void gfxShowPic(void); +void gfxShakeScreen(uint8_t times); +void gfxShowPriority(void); -// ----- Object helpers (agiObj.c) ----- +// ----- agiObj.c ----- -// Reset every object slot to the default empty state. Called by -// agiVmInit and again on every NEW_ROOM transition (matches AGI's -// canonical room-change reset). -void agiObjResetAll(AgiVmT *vm); - -// Single-object reset (animate.obj reuses this). -void agiObjReset(AgiObjectT *obj); +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 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); +void objSetLoop(uint8_t n, uint8_t loop); +bool objSetView(uint8_t n, uint8_t viewId); +void objUnanimateAll(void); +void objUpdateAll(void); -// ----- Text helpers (agiText.c) ----- +// ----- agiText.c ----- -// Fetch the host font surface (built lazily on first call). Returned -// surface contains 96 ASCII glyphs (codes 32..127) laid out 16 wide -// by 6 tall. Caller must NOT free. -const jlSurfaceT *agiTextFontSurface(void); - -// asciiMap suitable for jlDrawText against the font surface above. -const uint16_t *agiTextAsciiMap(void); - -// Render the VM's text plane (status line + display rows + open -// print modal) onto `stage`. Called by host after the picture + -// objects are drawn. -void agiTextRender(const AgiVmT *vm, jlSurfaceT *stage); +void textClearLines(uint8_t top, uint8_t bottom, uint8_t color); +void textClearRect(uint8_t top, uint8_t left, uint8_t bottom, uint8_t right, uint8_t color); +void textCloseWindow(void); +void textDisplay(uint8_t row, uint8_t col, const char *text); +void textDrawInputLine(void); +void textDrawStatus(void); +void textEchoInput(uint8_t oldLen); +void textExpand(const char *src, char *dst, uint16_t dstCap); +void textGraphicsMode(void); +void textInventory(void); +void textMenuAdd(const char *name); +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); +void textSetAttribute(uint8_t fg, uint8_t bg); +void textShowObject(uint8_t viewId); +void textTextMode(void); +void textUpdateStatus(void); -// ----- PIC compositing (agiPic.c addendum) ----- +// ----- agiInput.c ----- -// Bake a VIEW cel into the PIC visual + priority planes (the -// implementation of add.to.pic). priColor 4..15 sets the priority -// pixels; priColor == 0 means "use the priority of the actor's Y -// band" (AGI default). margin 0..3 reserves a control-line band at -// the cel's bottom so add.to.pic art can suggest a walkable edge; -// margin 4 means "no margin". -void agiPicAddView(AgiPicT *pic, const AgiViewT *view, - uint8_t loop, uint8_t cel, - int16_t x, int16_t y, - uint8_t priColor, uint8_t margin); +bool inputGetNumber(const char *prompt, uint8_t *outValue); +bool inputGetString(char *dst, uint8_t maxLen, uint8_t row, uint8_t col, const char *prompt); +bool inputHaveKey(void); +void inputParse(const char *line); +void inputPoll(void); +void inputProcess(void); +void inputReset(void); +uint16_t inputWaitKey(void); +bool inputWaitPrintClose(uint16_t timeoutHalfSeconds); + + +// ----- agiSound.c ----- + +void soundInit(void); +void soundPump(void); +void soundShutdown(void); +void soundStart(uint8_t soundId, uint8_t flag); +void soundStop(void); + + +// ----- agiSave.c ----- + +bool restoreGame(void); +void saveGame(void); +void saveScriptAdd(uint8_t op, uint8_t arg); +void saveScriptAddArgs(uint8_t op, const uint8_t *args); +void saveScriptClear(void); + + +// ----- agiVm.c ----- + +void vmCallLogic(uint8_t logicId); +void vmDiscardLogics(void); +bool vmLoadLogic(uint8_t logicId); +const char *vmMessage(uint8_t logicId, uint8_t msgId); +void vmReplay(const AgiScriptEntryT *e); + + +// ----- agi.c ----- + +void agiIdle(void); +void agiNewRoom(uint8_t room); +void agiObjectStatus(uint8_t n); +void agiQuit(bool immediately); +void agiResetState(void); +void agiRestartGame(void); #endif diff --git a/agiFont.c b/agiFont.c new file mode 100644 index 0000000..be6625b --- /dev/null +++ b/agiFont.c @@ -0,0 +1,104 @@ +// The text font: an 8x8 cell per character from space (0x20) to tilde +// (0x7E), one byte per row with the leftmost pixel in bit 7. Drawn for +// JoeyAGI (it is not the IBM PC's ROM font Sierra's interpreter shows). + +#include "agi.h" + + +const uint8_t kAgiFont[AGI_FONT_GLYPHS][AGI_FONT_ROWS] = { + { 0, 0, 0, 0, 0, 0, 0, 0 }, // 0x20 ' ' + { 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, 0x18, 0x00 }, // 0x21 '!' + { 0x6C, 0x6C, 0x48, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x22 '"' + { 0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00 }, // 0x23 '#' + { 0x18, 0x3E, 0x60, 0x3C, 0x06, 0x7C, 0x18, 0x00 }, // 0x24 '$' + { 0x66, 0x66, 0x0C, 0x18, 0x30, 0x66, 0x66, 0x00 }, // 0x25 '%' + { 0x38, 0x6C, 0x38, 0x76, 0xDC, 0xCC, 0x76, 0x00 }, // 0x26 '&' + { 0x18, 0x18, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x27 '\'' + { 0x0C, 0x18, 0x30, 0x30, 0x30, 0x18, 0x0C, 0x00 }, // 0x28 '(' + { 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x18, 0x30, 0x00 }, // 0x29 ')' + { 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00 }, // 0x2A '*' + { 0x00, 0x18, 0x18, 0x7E, 0x18, 0x18, 0x00, 0x00 }, // 0x2B '+' + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x30 }, // 0x2C ',' + { 0x00, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x00 }, // 0x2D '-' + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00 }, // 0x2E '.' + { 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0, 0x80, 0x00 }, // 0x2F '/' + { 0x3C, 0x66, 0x6E, 0x76, 0x66, 0x66, 0x3C, 0x00 }, // 0x30 '0' + { 0x18, 0x38, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00 }, // 0x31 '1' + { 0x3C, 0x66, 0x06, 0x1C, 0x30, 0x66, 0x7E, 0x00 }, // 0x32 '2' + { 0x3C, 0x66, 0x06, 0x1C, 0x06, 0x66, 0x3C, 0x00 }, // 0x33 '3' + { 0x0C, 0x1C, 0x3C, 0x6C, 0x7E, 0x0C, 0x0C, 0x00 }, // 0x34 '4' + { 0x7E, 0x60, 0x7C, 0x06, 0x06, 0x66, 0x3C, 0x00 }, // 0x35 '5' + { 0x1C, 0x30, 0x60, 0x7C, 0x66, 0x66, 0x3C, 0x00 }, // 0x36 '6' + { 0x7E, 0x66, 0x0C, 0x18, 0x18, 0x18, 0x18, 0x00 }, // 0x37 '7' + { 0x3C, 0x66, 0x66, 0x3C, 0x66, 0x66, 0x3C, 0x00 }, // 0x38 '8' + { 0x3C, 0x66, 0x66, 0x3E, 0x06, 0x0C, 0x38, 0x00 }, // 0x39 '9' + { 0x00, 0x18, 0x18, 0x00, 0x00, 0x18, 0x18, 0x00 }, // 0x3A ':' + { 0x00, 0x18, 0x18, 0x00, 0x00, 0x18, 0x18, 0x30 }, // 0x3B ';' + { 0x0C, 0x18, 0x30, 0x60, 0x30, 0x18, 0x0C, 0x00 }, // 0x3C '<' + { 0x00, 0x00, 0x7E, 0x00, 0x7E, 0x00, 0x00, 0x00 }, // 0x3D '=' + { 0x30, 0x18, 0x0C, 0x06, 0x0C, 0x18, 0x30, 0x00 }, // 0x3E '>' + { 0x3C, 0x66, 0x06, 0x0C, 0x18, 0x00, 0x18, 0x00 }, // 0x3F '?' + { 0x3C, 0x66, 0x6E, 0x6E, 0x60, 0x62, 0x3C, 0x00 }, // 0x40 '@' + { 0x18, 0x3C, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x00 }, // 0x41 'A' + { 0x7C, 0x66, 0x66, 0x7C, 0x66, 0x66, 0x7C, 0x00 }, // 0x42 'B' + { 0x3C, 0x66, 0x60, 0x60, 0x60, 0x66, 0x3C, 0x00 }, // 0x43 'C' + { 0x78, 0x6C, 0x66, 0x66, 0x66, 0x6C, 0x78, 0x00 }, // 0x44 'D' + { 0x7E, 0x60, 0x60, 0x78, 0x60, 0x60, 0x7E, 0x00 }, // 0x45 'E' + { 0x7E, 0x60, 0x60, 0x78, 0x60, 0x60, 0x60, 0x00 }, // 0x46 'F' + { 0x3C, 0x66, 0x60, 0x6E, 0x66, 0x66, 0x3C, 0x00 }, // 0x47 'G' + { 0x66, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x66, 0x00 }, // 0x48 'H' + { 0x3C, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00 }, // 0x49 'I' + { 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x6C, 0x38, 0x00 }, // 0x4A 'J' + { 0x66, 0x6C, 0x78, 0x70, 0x78, 0x6C, 0x66, 0x00 }, // 0x4B 'K' + { 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x7E, 0x00 }, // 0x4C 'L' + { 0x63, 0x77, 0x7F, 0x6B, 0x63, 0x63, 0x63, 0x00 }, // 0x4D 'M' + { 0x66, 0x76, 0x7E, 0x7E, 0x6E, 0x66, 0x66, 0x00 }, // 0x4E 'N' + { 0x3C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00 }, // 0x4F 'O' + { 0x7C, 0x66, 0x66, 0x7C, 0x60, 0x60, 0x60, 0x00 }, // 0x50 'P' + { 0x3C, 0x66, 0x66, 0x66, 0x6A, 0x6C, 0x36, 0x00 }, // 0x51 'Q' + { 0x7C, 0x66, 0x66, 0x7C, 0x6C, 0x66, 0x66, 0x00 }, // 0x52 'R' + { 0x3C, 0x66, 0x60, 0x3C, 0x06, 0x66, 0x3C, 0x00 }, // 0x53 'S' + { 0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00 }, // 0x54 'T' + { 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00 }, // 0x55 'U' + { 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x18, 0x00 }, // 0x56 'V' + { 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00 }, // 0x57 'W' + { 0x66, 0x66, 0x3C, 0x18, 0x3C, 0x66, 0x66, 0x00 }, // 0x58 'X' + { 0x66, 0x66, 0x66, 0x3C, 0x18, 0x18, 0x18, 0x00 }, // 0x59 'Y' + { 0x7E, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x7E, 0x00 }, // 0x5A 'Z' + { 0x3C, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3C, 0x00 }, // 0x5B '[' + { 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x02, 0x00 }, // 0x5C '\\' + { 0x3C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x3C, 0x00 }, // 0x5D ']' + { 0x18, 0x3C, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x5E '^' + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF }, // 0x5F '_' + { 0x30, 0x18, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x60 '`' + { 0x00, 0x00, 0x3C, 0x06, 0x3E, 0x66, 0x3E, 0x00 }, // 0x61 'a' + { 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x7C, 0x00 }, // 0x62 'b' + { 0x00, 0x00, 0x3C, 0x66, 0x60, 0x66, 0x3C, 0x00 }, // 0x63 'c' + { 0x06, 0x06, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x00 }, // 0x64 'd' + { 0x00, 0x00, 0x3C, 0x66, 0x7E, 0x60, 0x3C, 0x00 }, // 0x65 'e' + { 0x1C, 0x36, 0x30, 0x78, 0x30, 0x30, 0x30, 0x00 }, // 0x66 'f' + { 0x00, 0x00, 0x3E, 0x66, 0x66, 0x3E, 0x06, 0x3C }, // 0x67 'g' + { 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x00 }, // 0x68 'h' + { 0x18, 0x00, 0x38, 0x18, 0x18, 0x18, 0x3C, 0x00 }, // 0x69 'i' + { 0x06, 0x00, 0x06, 0x06, 0x06, 0x06, 0x66, 0x3C }, // 0x6A 'j' + { 0x60, 0x60, 0x66, 0x6C, 0x78, 0x6C, 0x66, 0x00 }, // 0x6B 'k' + { 0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00 }, // 0x6C 'l' + { 0x00, 0x00, 0x66, 0x7F, 0x7F, 0x6B, 0x63, 0x00 }, // 0x6D 'm' + { 0x00, 0x00, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x00 }, // 0x6E 'n' + { 0x00, 0x00, 0x3C, 0x66, 0x66, 0x66, 0x3C, 0x00 }, // 0x6F 'o' + { 0x00, 0x00, 0x7C, 0x66, 0x66, 0x7C, 0x60, 0x60 }, // 0x70 'p' + { 0x00, 0x00, 0x3E, 0x66, 0x66, 0x3E, 0x06, 0x06 }, // 0x71 'q' + { 0x00, 0x00, 0x7C, 0x66, 0x60, 0x60, 0x60, 0x00 }, // 0x72 'r' + { 0x00, 0x00, 0x3E, 0x60, 0x3C, 0x06, 0x7C, 0x00 }, // 0x73 's' + { 0x18, 0x18, 0x7E, 0x18, 0x18, 0x18, 0x0E, 0x00 }, // 0x74 't' + { 0x00, 0x00, 0x66, 0x66, 0x66, 0x66, 0x3E, 0x00 }, // 0x75 'u' + { 0x00, 0x00, 0x66, 0x66, 0x66, 0x3C, 0x18, 0x00 }, // 0x76 'v' + { 0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00 }, // 0x77 'w' + { 0x00, 0x00, 0x66, 0x3C, 0x18, 0x3C, 0x66, 0x00 }, // 0x78 'x' + { 0x00, 0x00, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x3C }, // 0x79 'y' + { 0x00, 0x00, 0x7E, 0x0C, 0x18, 0x30, 0x7E, 0x00 }, // 0x7A 'z' + { 0x0E, 0x18, 0x18, 0x70, 0x18, 0x18, 0x0E, 0x00 }, // 0x7B '{' + { 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00 }, // 0x7C '|' + { 0x70, 0x18, 0x18, 0x0E, 0x18, 0x18, 0x70, 0x00 }, // 0x7D '}' + { 0x76, 0xDC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x7E '~' +}; diff --git a/agiGfx.c b/agiGfx.c new file mode 100644 index 0000000..0c65a1c --- /dev/null +++ b/agiGfx.c @@ -0,0 +1,696 @@ +// The picture buffers and the screen objects drawn into them. +// +// Like Sierra's interpreter, JoeyAGI keeps one 160x168 picture buffer pair +// -- a colour and a priority byte per AGI pixel -- that holds the picture +// with the visible objects blitted into it. An object's blit saves the +// bytes under its cel, then draws the cel's pixels whose priority is at +// least the buffer's, writing the object's priority with the colour, so a +// lower object drawn later stays hidden behind it. Erasing puts the saved +// bytes back, so blits stack: the buffer is always erased in the reverse +// order it was drawn. Objects that are not updated (stop.update) are drawn +// first, sorted by depth, and the updated ones on top of them. +// +// Changed areas are copied to the stage, each AGI pixel two screen +// pixels wide, below the status line (configure.screen's picture row). +// display() text written over the picture lives only on the stage, so an +// object moving across it wipes it, as in the original. + +#include "agi.h" + +#include +#include + +#include "joey/core.h" +#include "joey/draw.h" +#include "joey/input.h" +#include "joey/present.h" +#include "joey/sprite.h" +#include "joey/tile.h" +#include "surfaceInternal.h" + + +// Room for the save-unders of every drawn object: two bytes per pixel. +#define SAVE_ARENA_BYTES 65535u +// Areas waiting to be copied to the stage; more are merged into one. +#define DIRTY_MAX 24u +#define NIBBLE_SHIFT 4u +// A cel run byte: the colour in the high nibble, the length in the low. +#define RLE_COUNT_MASK 0x0Fu +// add.to.pic's margin box: margins 0..3 are control values; 4 and above +// mean no box. +#define MARGIN_NONE 4u +// shake.screen, measured on Sierra's interpreter: each beat shows the +// whole screen moved 8 pixels right and 4 down (the uncovered edges +// black) for 4 frames, then in place for 4; a shake is 4 beats per unit +// counted in 16 bits, so shake.screen(0) is 65536 beats. +#define SHAKE_DX 8 +#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 (SURFACE_WIDTH / SURFACE_BYTES_PER_ROW) + + +typedef struct { + int16_t x; + int16_t y; + int16_t w; + int16_t h; +} GfxRectT; + + +// ----- Prototypes ----- + +static void blitObject(uint8_t n); +static void buildShifted(jlSurfaceT *shifted, const jlSurfaceT *normal); +static void drawCel(const AgiCelT *cel, bool mirror, int16_t left, int16_t top, uint8_t priority, bool keepPriority); +static void drawList(bool updating); +static uint8_t effectivePriority(int16_t x, int16_t y); +static void markDirty(int16_t x, int16_t y, int16_t w, int16_t h); +static void pushRect(const GfxRectT *r); +static void pushRuns(jlSurfaceT *stage, int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t screenY); +static void restoreRect(const uint8_t *save, int16_t x, int16_t y, uint8_t w, uint8_t h); +static void saveRect(uint8_t *save, int16_t x, int16_t y, uint8_t w, uint8_t h); +static void shakePhase(const jlSurfaceT *show, uint32_t phaseMs); +static int16_t sortKey(const AgiObjectT *o); +static void unblitTop(void); + + +// ----- Module state ----- + +static uint8_t *gVis; +static uint8_t *gPri; +static uint8_t *gSaveArena; +static uint16_t gSaveTop; +// Objects in the order they were blitted; the first gStaticBlits of them +// are the not-updated batch. +static uint8_t gBlitStack[AGI_MAX_OBJECTS]; +static uint8_t gBlitCount; +static uint8_t gStaticBlits; +static GfxRectT gDirty[DIRTY_MAX]; +static uint8_t gDirtyCount; +static bool gPicShown; + + +// ----- Internal helpers (alphabetical) ----- + +static void blitObject(uint8_t n) { + AgiObjectT *o; + const AgiCelT *cel; + bool mirror; + int16_t left; + int16_t top; + uint16_t need; + + o = &gAgi->objects[n]; + cel = agiViewCel(o->view, o->loop, o->cel, &mirror); + if (cel == NULL || cel->width == 0u || cel->height == 0u) { + return; + } + left = o->x; + top = (int16_t)(o->y - (int16_t)cel->height + 1); + // Clip the save rectangle to the buffer. + o->blitX = left; + o->blitY = (top < 0) ? 0 : top; + o->blitW = cel->width; + if (left + (int16_t)cel->width > AGI_WIDTH) { + o->blitW = (uint8_t)(AGI_WIDTH - left); + } + o->blitH = (uint8_t)(o->y - o->blitY + 1); + if (left < 0 || left >= AGI_WIDTH || o->y < 0 || o->y > AGI_MAX_Y || o->blitW == 0u) { + return; + } + need = (uint16_t)((uint16_t)o->blitW * o->blitH * 2u); + if ((uint32_t)gSaveTop + need > SAVE_ARENA_BYTES) { + return; + } + o->saveUnder = &gSaveArena[gSaveTop]; + saveRect(o->saveUnder, left, o->blitY, o->blitW, o->blitH); + gSaveTop = (uint16_t)(gSaveTop + need); + o->blitted = true; + gBlitStack[gBlitCount] = n; + gBlitCount++; + if ((o->flags & OBJ_FIX_PRIORITY) == 0u) { + o->priority = agiPriorityForY(o->y); + } + drawCel(cel, mirror, left, top, o->priority, false); + markDirty(o->blitX, o->blitY, o->blitW, o->blitH); +} + + +// The screen as a shake beat shows it: moved SHAKE_DX right and SHAKE_DY +// down over black. Chunky surfaces move their bytes; planar ones draw the +// screen as one big sprite (colour 0 is transparent, and the surface is +// black beneath). +static void buildShifted(jlSurfaceT *shifted, const jlSurfaceT *normal) { + jlSpriteT *sprite; + uint16_t y; + + jlSurfaceCopy(shifted, normal); + jlSurfaceClear(shifted, AGI_COLOR_BLACK); + if (shifted->pixels != NULL && normal->pixels != NULL) { + for (y = SHAKE_DY; y < SURFACE_HEIGHT; y++) { + memcpy(&shifted->pixels[y * SURFACE_BYTES_PER_ROW + SHAKE_DX / CHUNKY_PX_PER_BYTE], + &normal->pixels[(uint16_t)(y - SHAKE_DY) * SURFACE_BYTES_PER_ROW], + SURFACE_BYTES_PER_ROW - SHAKE_DX / CHUNKY_PX_PER_BYTE); + } + return; + } + sprite = jlSpriteCreateFromSurface(normal, 0, 0, TILE_BLOCKS_PER_ROW, TILE_BLOCKS_PER_COL); + if (sprite != NULL) { + jlSpriteDraw(shifted, sprite, SHAKE_DX, SHAKE_DY); + jlSpriteDestroy(sprite); + } +} + + +// Draw a cel's opaque pixels into the buffers where its priority is at +// least the buffer's (control lines take the priority below them). +// keepPriority leaves the priority buffer alone (add.to.pic writes it). +static void drawCel(const AgiCelT *cel, bool mirror, int16_t left, int16_t top, uint8_t priority, bool keepPriority) { + const uint8_t *rle; + uint8_t row; + int16_t col; + int16_t y; + int16_t x; + uint8_t color; + uint8_t run; + uint16_t idx; + + rle = cel->rle; + for (row = 0u; row < cel->height; row++) { + y = (int16_t)(top + row); + col = 0; + while (*rle != 0u) { + color = (uint8_t)(*rle >> NIBBLE_SHIFT); + run = (uint8_t)(*rle & RLE_COUNT_MASK); + rle++; + if (color == cel->transparent || y < 0 || y > AGI_MAX_Y) { + col = (int16_t)(col + run); + continue; + } + while (run > 0u) { + x = mirror ? (int16_t)(left + (int16_t)cel->width - 1 - col) : (int16_t)(left + col); + if (x >= 0 && x < AGI_WIDTH && col < (int16_t)cel->width) { + idx = (uint16_t)((uint16_t)y * AGI_WIDTH + (uint16_t)x); + if (priority >= effectivePriority(x, y)) { + gVis[idx] = color; + if (!keepPriority) { + gPri[idx] = priority; + } + } + } + col++; + run--; + } + } + rle++; + } +} + + +// Blit one batch, sorted by depth: the not-updated objects or the updated +// ones. +static void drawList(bool updating) { + uint8_t order[AGI_MAX_OBJECTS]; + uint8_t count; + uint8_t i; + uint8_t j; + uint16_t want; + + want = updating ? (uint16_t)(OBJ_ANIMATED | OBJ_DRAWN | OBJ_UPDATE) : (uint16_t)(OBJ_ANIMATED | OBJ_DRAWN); + count = 0u; + for (i = 0u; i < gAgi->objectCount; i++) { + const AgiObjectT *o = &gAgi->objects[i]; + + if ((o->flags & (OBJ_ANIMATED | OBJ_DRAWN | OBJ_UPDATE)) != want || o->blitted || o->view == NULL) { + continue; + } + // Insertion sort: by depth, then by object number. + j = count; + while (j > 0u && sortKey(&gAgi->objects[order[j - 1u]]) > sortKey(o)) { + order[j] = order[j - 1u]; + j--; + } + order[j] = i; + count++; + } + for (i = 0u; i < count; i++) { + blitObject(order[i]); + } +} + + +// The priority a pixel has for drawing: control lines (0..3) take the +// priority of the first non-control pixel below them. +static uint8_t effectivePriority(int16_t x, int16_t y) { + uint8_t p; + + for (;;) { + p = gPri[(uint16_t)y * AGI_WIDTH + (uint16_t)x]; + if (p > AGI_CTL_WATER) { + return p; + } + y++; + if (y > AGI_MAX_Y) { + return AGI_PRIORITY_MAX; + } + } +} + + +static void markDirty(int16_t x, int16_t y, int16_t w, int16_t h) { + GfxRectT *r; + int16_t right; + int16_t bottom; + + if (w <= 0 || h <= 0) { + return; + } + if (gDirtyCount < DIRTY_MAX) { + r = &gDirty[gDirtyCount]; + gDirtyCount++; + r->x = x; + r->y = y; + r->w = w; + r->h = h; + return; + } + // Out of slots: grow the last one to cover this one too. + r = &gDirty[DIRTY_MAX - 1u]; + right = (int16_t)(r->x + r->w); + bottom = (int16_t)(r->y + r->h); + if (x + w > right) { + right = (int16_t)(x + w); + } + if (y + h > bottom) { + bottom = (int16_t)(y + h); + } + if (x < r->x) { + r->x = x; + } + if (y < r->y) { + r->y = y; + } + r->w = (int16_t)(right - r->x); + r->h = (int16_t)(bottom - r->y); +} + + +// Copy a buffer rectangle to the stage, each AGI pixel two screen pixels: +// straight into a chunky stage's bytes, or as runs on a planar one. +static void pushRect(const GfxRectT *r) { + jlSurfaceT *stage; + int16_t x0; + int16_t y0; + int16_t x1; + int16_t y1; + int16_t x; + int16_t y; + int16_t screenY; + uint8_t c; + + x0 = (r->x < 0) ? 0 : r->x; + y0 = (r->y < 0) ? 0 : r->y; + x1 = (int16_t)(r->x + r->w); + y1 = (int16_t)(r->y + r->h); + if (x1 > AGI_WIDTH) { + x1 = AGI_WIDTH; + } + if (y1 > AGI_HEIGHT) { + y1 = AGI_HEIGHT; + } + if (x0 >= x1 || y0 >= y1) { + return; + } + stage = jlStageGet(); + screenY = (int16_t)((int16_t)gAgi->picRow * AGI_CELL_PX); + if (stage->pixels == NULL) { + pushRuns(stage, x0, y0, x1, y1, screenY); + return; + } + for (y = y0; y < y1; y++) { + const uint8_t *src = &gVis[(uint16_t)y * AGI_WIDTH]; + uint8_t *dst = &stage->pixels[(uint16_t)(screenY + y) * SURFACE_BYTES_PER_ROW]; + + for (x = x0; x < x1; x++) { + c = src[x]; + dst[x] = (uint8_t)((c << NIBBLE_SHIFT) | c); + } + } + surfaceMarkDirtyRect(stage, (int16_t)(x0 * AGI_SCREEN_PX_PER_AGI), (int16_t)(screenY + y0), + (int16_t)((x1 - x0) * AGI_SCREEN_PX_PER_AGI), (int16_t)(y1 - y0)); +} + + +// Planar stages (Amiga, ST, X68000) have no bytes to write: each row goes +// out as runs of one colour through jlFillRect, which the ports do fast. +static void pushRuns(jlSurfaceT *stage, int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t screenY) { + const uint8_t *src; + int16_t x; + int16_t y; + int16_t run; + uint8_t c; + + for (y = y0; y < y1; y++) { + src = &gVis[(uint16_t)y * AGI_WIDTH]; + x = x0; + while (x < x1) { + c = src[x]; + run = 1; + while (x + run < x1 && src[x + run] == c) { + run++; + } + jlFillRect(stage, (int16_t)(x * AGI_SCREEN_PX_PER_AGI), (int16_t)(screenY + y), (uint16_t)(run * AGI_SCREEN_PX_PER_AGI), 1u, c); + x = (int16_t)(x + run); + } + } +} + + +// Put back a rectangle of both buffers that saveRect kept. +static void restoreRect(const uint8_t *save, int16_t x, int16_t y, uint8_t w, uint8_t h) { + uint8_t row; + uint16_t dst; + + for (row = 0u; row < h; row++) { + dst = (uint16_t)((uint16_t)(y + row) * AGI_WIDTH + (uint16_t)x); + memcpy(&gVis[dst], save, w); + save += w; + memcpy(&gPri[dst], save, w); + save += w; + } +} + + +// Keep a rectangle of both buffers, a row of colours then a row of +// priorities at a time (2 * w * h bytes). +static void saveRect(uint8_t *save, int16_t x, int16_t y, uint8_t w, uint8_t h) { + uint8_t row; + uint16_t src; + + for (row = 0u; row < h; row++) { + src = (uint16_t)((uint16_t)(y + row) * AGI_WIDTH + (uint16_t)x); + memcpy(save, &gVis[src], w); + save += w; + memcpy(save, &gPri[src], w); + save += w; + } +} + + +// One phase of a shake beat: `show` on the screen for phaseMs (NULL, for +// want of memory, is only the wait). +static void shakePhase(const jlSurfaceT *show, uint32_t phaseMs) { + uint32_t start; + + if (show != NULL) { + jlSurfaceCopy(jlStageGet(), show); + } + start = jlMillisElapsed(); + do { + agiIdle(); + } while (jlMillisElapsed() - start < phaseMs); +} + + +// Objects sort by their baseline, or by the top of their band when their +// priority is fixed. +static int16_t sortKey(const AgiObjectT *o) { + if ((o->flags & OBJ_FIX_PRIORITY) != 0u) { + return agiYForPriority(o->priority); + } + return o->y; +} + + +static void unblitTop(void) { + AgiObjectT *o; + + gBlitCount--; + o = &gAgi->objects[gBlitStack[gBlitCount]]; + restoreRect(o->saveUnder, o->blitX, o->blitY, o->blitW, o->blitH); + gSaveTop = (uint16_t)(o->saveUnder - gSaveArena); + o->blitted = false; + markDirty(o->blitX, o->blitY, o->blitW, o->blitH); +} + + +// ----- Public API (alphabetical) ----- + +// add.to.pic: draw a cel into the picture itself with a fixed priority +// (0 means the priority of its baseline) and, for margins 0..3, a box of +// that control value along its base. +void gfxAddToPic(uint8_t viewId, uint8_t loop, uint8_t cel, int16_t x, int16_t y, uint8_t priority, uint8_t margin) { + const AgiViewT *view; + const AgiCelT *c; + bool mirror; + int16_t top; + int16_t boxTop; + int16_t i; + + view = agiViewGet(viewId); + c = agiViewCel(view, loop, cel, &mirror); + if (c == NULL) { + return; + } + if (priority < AGI_PRIORITY_MIN) { + priority = agiPriorityForY(y); + } + gfxEraseAll(); + top = (int16_t)(y - (int16_t)c->height + 1); + drawCel(c, mirror, x, top, priority, false); + // The margin box (measured): an outline of the control value, as wide + // as the cel, from its baseline up to the top of the baseline's band. + if (margin < MARGIN_NONE) { + boxTop = agiYForPriority(agiPriorityForY(y)); + for (i = 0; i < (int16_t)c->width; i++) { + int16_t yy; + + if (x + i < 0 || x + i > AGI_MAX_X) { + continue; + } + for (yy = boxTop; yy <= y; yy++) { + if (yy == y || yy == boxTop || i == 0 || i == (int16_t)c->width - 1) { + gPri[(uint16_t)yy * AGI_WIDTH + (uint16_t)(x + i)] = margin; + } + } + } + } + markDirty(x, top, c->width, c->height); + gfxRedrawAll(); +} + + +// The priority or control value at an AGI pixel, for collision tests. +uint8_t gfxControlAt(int16_t x, int16_t y) { + if (x < 0 || x > AGI_MAX_X || y < 0 || y > AGI_MAX_Y) { + return AGI_PRIORITY_MAX; + } + return gPri[(uint16_t)y * AGI_WIDTH + (uint16_t)x]; +} + + +// draw.pic (clear first) and overlay.pic: draw a picture into the buffers +// under the objects. The screen keeps the old picture until show.pic. +void gfxDrawPic(uint8_t picId, bool clearFirst) { + uint8_t *data; + uint16_t length; + + data = agiResLoad(&gAgi->game, AGI_RES_PIC, picId, &length); + if (data == NULL) { + return; + } + gfxEraseAll(); + if (clearFirst) { + memset(gVis, AGI_PIC_BG_COLOR, AGI_PIXELS); + memset(gPri, AGI_PIC_BG_PRIORITY, AGI_PIXELS); + } + (void)agiPicDecode(gVis, gPri, data, length); + jlFree(data); + gDirtyCount = 0u; + gPicShown = false; + drawList(false); + gStaticBlits = gBlitCount; + drawList(true); + gDirtyCount = 0u; +} + + +// Draw the objects that are drawn but not yet blitted (after a change), +// keeping the not-updated ones under the updated ones. +void gfxDrawStatic(void) { + drawList(false); + gStaticBlits = gBlitCount; + drawList(true); +} + + +void gfxEraseAll(void) { + while (gBlitCount > 0u) { + unblitTop(); + } + gStaticBlits = 0u; +} + + +void gfxEraseUpdating(void) { + while (gBlitCount > gStaticBlits) { + unblitTop(); + } +} + + +void gfxFree(void) { + jlFree(gVis); + jlFree(gPri); + jlFree(gSaveArena); + gVis = NULL; + gPri = NULL; + gSaveArena = NULL; +} + + +bool gfxInit(void) { + gVis = (uint8_t *)jlAlloc(AGI_PIXELS); + gPri = (uint8_t *)jlAlloc(AGI_PIXELS); + gSaveArena = (uint8_t *)jlAlloc(SAVE_ARENA_BYTES); + if (gVis == NULL || gPri == NULL || gSaveArena == NULL) { + gfxFree(); + return false; + } + memset(gVis, AGI_COLOR_BLACK, AGI_PIXELS); + memset(gPri, AGI_PIC_BG_PRIORITY, AGI_PIXELS); + gSaveTop = 0u; + gBlitCount = 0u; + gStaticBlits = 0u; + gDirtyCount = 0u; + gPicShown = false; + return true; +} + + +// Copy every area that changed since the last call to the stage. +void gfxPresentDirty(void) { + uint8_t i; + + if (gPicShown && !gAgi->textMode) { + for (i = 0u; i < gDirtyCount; i++) { + pushRect(&gDirty[i]); + } + } + gDirtyCount = 0u; +} + + +// After a change to which objects are drawn or updated: take them all off +// and put them back in the right order. +void gfxRedrawAll(void) { + gfxEraseAll(); + gfxDrawStatic(); + gfxPresentDirty(); +} + + +void gfxRedrawUpdating(void) { + drawList(true); +} + + +// Copy the whole picture area to the stage (after text mode or a window). +void gfxRestorePicture(void) { + GfxRectT r; + + if (!gPicShown || gAgi->textMode) { + return; + } + r.x = 0; + r.y = 0; + r.w = AGI_WIDTH; + r.h = AGI_HEIGHT; + pushRect(&r); +} + + +// shake.screen: the whole screen jumps and comes back, beat after beat, +// as long as the game waits (keys, sound and the clock carry on). Each +// phase lasts SHAKE_PHASE_FRAMES of this display's frames, counted from +// when it was put up, so a machine that takes longer than that to change +// the whole screen (the IIgs) shows each phase for as long as that takes. +// Without the memory for the two screens it is just as long a pause. +void gfxShakeScreen(uint8_t times) { + jlSurfaceT *normal; + jlSurfaceT *shifted; + uint16_t beats; + uint32_t phaseMs; + + normal = jlSurfaceCreate(); + shifted = jlSurfaceCreate(); + if (normal == NULL || shifted == NULL) { + jlSurfaceDestroy(shifted); + jlSurfaceDestroy(normal); + normal = NULL; + shifted = NULL; + } else { + jlSurfaceCopy(normal, jlStageGet()); + buildShifted(shifted, normal); + } + phaseMs = (uint32_t)SHAKE_PHASE_FRAMES * MS_PER_SECOND / jlFrameHz(); + beats = (uint16_t)((uint16_t)times * SHAKE_BEATS_PER_UNIT); + do { + shakePhase(shifted, phaseMs); + shakePhase(normal, phaseMs); + beats--; + } while (beats != 0u); + jlSurfaceDestroy(shifted); + jlSurfaceDestroy(normal); +} + + +// show.obj: a view's first cel at the bottom middle of the picture, on +// the stage only: drawn into the buffers over everything, copied out and +// the buffers put back (the caller restores the screen after). +void gfxShowObjectCel(uint8_t viewId) { + const AgiCelT *cel; + bool mirror; + GfxRectT r; + uint8_t *save; + + cel = agiViewCel(agiViewGet(viewId), 0u, 0u, &mirror); + if (cel == NULL || cel->width > AGI_WIDTH || cel->height > AGI_HEIGHT) { + return; + } + r.x = (int16_t)((AGI_MAX_X - (int16_t)cel->width) / 2); + r.y = (int16_t)(AGI_HEIGHT - (int16_t)cel->height); + r.w = cel->width; + r.h = cel->height; + if ((uint32_t)gSaveTop + 2u * (uint32_t)r.w * (uint32_t)r.h > SAVE_ARENA_BYTES) { + return; + } + save = &gSaveArena[gSaveTop]; + saveRect(save, r.x, r.y, cel->width, cel->height); + drawCel(cel, mirror, r.x, r.y, AGI_PRIORITY_MAX, true); + pushRect(&r); + restoreRect(save, r.x, r.y, cel->width, cel->height); +} + + +void gfxShowPic(void) { + gPicShown = true; + gDirtyCount = 0u; + gfxRestorePicture(); +} + + +// show.pri.screen: the priority buffer as colours until a key. +void gfxShowPriority(void) { + uint8_t *saved; + + saved = gVis; + gVis = gPri; + gfxRestorePicture(); + gVis = saved; + (void)inputWaitKey(); + gfxRestorePicture(); +} diff --git a/agiInput.c b/agiInput.c new file mode 100644 index 0000000..6ef3ad4 --- /dev/null +++ b/agiInput.c @@ -0,0 +1,484 @@ +// The keyboard (and joystick) as AGI sees it, the command line and the +// parser (AGI specification 3.8 and 4.2's input commands). +// +// Keys become the 16-bit codes Sierra's interpreter uses: the character +// in the low byte, or for keys without one the PC scan code in the high +// byte (F1 is 0x3B00, Alt+D 0x2000, the arrows 0x48/0x50/0x4B/0x4D00). +// Each cycle every queued key goes, in order, to a set.key controller +// that names it, then to steering ego, then to the command line. Enter +// on the command line parses it: punctuation removed, lower case, the +// longest dictionary entries matched word by word, ignored words (group +// 0) dropped; f2 is set and f4 cleared. Parsing stops at an unknown word, +// which stays in the list (any-word and rest-of-line still match it) with +// its position in v9; a line of nothing but ignored words says nothing. + +#include "agi.h" + +#include +#include + +#include "joey/core.h" +#include "joey/input.h" +#include "joey/present.h" + + +#define KEY_QUEUE_SIZE 16u +#define ASCII_FIRST_PRINT 0x20u +#define ASCII_LAST_PRINT 0x7Eu +#define ASCII_CTRL_FIRST 1u +#define SCAN_SHIFT 8u +#define FKEY_SCAN_FIRST 0x3Bu +#define FKEY_COUNT 10u +#define JOY_DEADZONE 48 +#define HALF_SECOND_MS 500u +#define NUMBER_MAX_DIGITS 3u +#define NUMBER_MAX 255u +#define DECIMAL_BASE 10u +#define LETTER_COUNT 26u + +// Characters the parser treats as word breaks. +#define PARSE_SEPARATORS " ,.?!();:[]{}" + + +// ----- Prototypes ----- + +static uint16_t dequeue(void); +static bool editLine(char *line, uint8_t *len, uint8_t maxLen, uint16_t key); +static void enqueue(uint16_t key); +static void handleKey(uint16_t key); +static uint8_t joystickDirection(void); +static uint8_t keyDirection(uint16_t key); + + +// ----- Tables ----- + +// The scan code of each letter, for Alt+letter. +static const uint8_t kLetterScan[LETTER_COUNT] = { + 0x1E, 0x30, 0x2E, 0x20, 0x12, 0x21, 0x22, 0x23, 0x17, 0x24, 0x25, 0x26, 0x32, + 0x31, 0x18, 0x19, 0x10, 0x13, 0x1F, 0x14, 0x16, 0x2F, 0x11, 0x2D, 0x15, 0x2C +}; + +static const jlKeyE kFunctionKeys[FKEY_COUNT] = { + KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10 +}; + + +// ----- Module state ----- + +static uint16_t gQueue[KEY_QUEUE_SIZE]; +static uint8_t gQueueHead; +static uint8_t gQueueTail; +static uint8_t gJoyDir; +// 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; +// have.key has been asked this cycle. +static bool gHaveKeyAsked; + + +// ----- Internal helpers (alphabetical) ----- + +static uint16_t dequeue(void) { + uint16_t key; + + if (gQueueHead == gQueueTail) { + return AGI_KEY_NONE; + } + key = gQueue[gQueueTail]; + gQueueTail = (uint8_t)((gQueueTail + 1u) % KEY_QUEUE_SIZE); + return key; +} + + +// Apply one key to a line being typed. Returns true when it was Enter. +static bool editLine(char *line, uint8_t *len, uint8_t maxLen, uint16_t key) { + if (key == AGI_KEY_ENTER) { + return true; + } + if (key == AGI_KEY_BACKSPACE) { + if (*len > 0u) { + (*len)--; + line[*len] = '\0'; + } + return false; + } + if (key >= ASCII_FIRST_PRINT && key <= ASCII_LAST_PRINT && *len < maxLen) { + line[*len] = (char)key; + (*len)++; + line[*len] = '\0'; + } + return false; +} + + +static void enqueue(uint16_t key) { + uint8_t next; + + next = (uint8_t)((gQueueHead + 1u) % KEY_QUEUE_SIZE); + if (next == gQueueTail) { + return; + } + gQueue[gQueueHead] = key; + gQueueHead = next; +} + + +static void handleKey(uint16_t key) { + uint8_t i; + uint8_t dir; + uint8_t maxLen; + uint8_t oldLen; + + gAgi->vars[VAR_KEY] = (uint8_t)key; + for (i = 0u; i < gAgi->keyMapCount; i++) { + if (gAgi->keyMaps[i].key == key) { + gAgi->controllers[gAgi->keyMaps[i].controller] = 1u; + return; + } + } + dir = keyDirection(key); + if (dir != AGI_DIR_NONE) { + // A second press of the direction ego is walking stops it. + if (gAgi->playerControl) { + 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]; + } + return; + } + if (!gAgi->inputOn) { + gKeyUnused = true; + return; + } + maxLen = (uint8_t)(AGI_TEXT_COLS - 1u - strlen(gAgi->strings[0])); + if (gAgi->vars[VAR_INPUT_MAX] != 0u && gAgi->vars[VAR_INPUT_MAX] < maxLen) { + maxLen = gAgi->vars[VAR_INPUT_MAX]; + } + if (maxLen > AGI_INPUT_MAX) { + maxLen = AGI_INPUT_MAX; + } + oldLen = gAgi->inputLen; + if (!editLine(gAgi->inputLine, &gAgi->inputLen, maxLen, key)) { + textEchoInput(oldLen); + return; + } + if (gAgi->inputLen > 0u) { + strcpy(gAgi->lastLine, gAgi->inputLine); + inputParse(gAgi->inputLine); + gAgi->inputLine[0] = '\0'; + gAgi->inputLen = 0u; + } + textDrawInputLine(); +} + + +// The joystick, read as one of the eight directions. +static uint8_t joystickDirection(void) { + return objDirectionTo(0, 0, jlJoystickX(JOYSTICK_0), jlJoystickY(JOYSTICK_0), JOY_DEADZONE); +} + + +static uint8_t keyDirection(uint16_t key) { + switch (key) { + case AGI_KEY_UP: return AGI_DIR_N; + case AGI_KEY_RIGHT: return AGI_DIR_E; + case AGI_KEY_DOWN: return AGI_DIR_S; + case AGI_KEY_LEFT: return AGI_DIR_W; + default: return AGI_DIR_NONE; + } +} + + +// ----- Public API (alphabetical) ----- + +// get.num: a number typed after a prompt on the input line. +bool inputGetNumber(const char *prompt, uint8_t *outValue) { + char buf[NUMBER_MAX_DIGITS + 1u]; + uint8_t len; + uint16_t value; + uint8_t i; + + buf[0] = '\0'; + if (!inputGetString(buf, NUMBER_MAX_DIGITS, AGI_NO_POSITION, AGI_NO_POSITION, prompt)) { + return false; + } + value = 0u; + len = (uint8_t)strlen(buf); + for (i = 0u; i < len; i++) { + if (buf[i] < '0' || buf[i] > '9') { + break; + } + value = (uint16_t)(value * DECIMAL_BASE + (uint16_t)(buf[i] - '0')); + } + *outValue = (uint8_t)((value > NUMBER_MAX) ? NUMBER_MAX : value); + return true; +} + + +// get.string (and get.num): a line typed at (row, col) after a prompt; +// without a position, on the input line. Escape cancels. The typed text +// is left in dst. +bool inputGetString(char *dst, uint8_t maxLen, uint8_t row, uint8_t col, const char *prompt) { + char line[AGI_INPUT_MAX + 1u]; + char shown[AGI_INPUT_MAX + 2u]; + uint8_t len; + uint16_t key; + uint8_t fieldCol; + bool onInputLine; + bool done; + + if (maxLen > AGI_INPUT_MAX) { + maxLen = AGI_INPUT_MAX; + } + onInputLine = (row == AGI_NO_POSITION); + if (onInputLine) { + row = gAgi->inputRow; + col = 0u; + textClearLines(row, row, AGI_COLOR_BLACK); + } + fieldCol = (uint8_t)(col + strlen(prompt)); + textDisplay(row, col, prompt); + line[0] = '\0'; + len = 0u; + done = false; + while (!done) { + memcpy(shown, line, len); + shown[len] = gAgi->cursorChar; + shown[len + 1u] = '\0'; + textClearRect(row, fieldCol, row, (uint8_t)(fieldCol + maxLen), AGI_COLOR_BLACK); + textDisplay(row, fieldCol, shown); + key = inputWaitKey(); + if (key == AGI_KEY_ESCAPE) { + break; + } + done = editLine(line, &len, maxLen, key); + } + if (onInputLine) { + textDrawInputLine(); + } + if (!done) { + return false; + } + strcpy(dst, line); + return true; +} + + +// 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. +bool inputHaveKey(void) { + uint16_t key; + + if (gKeyUnused) { + gKeyUnused = false; + return true; + } + if (!gHaveKeyAsked) { + gHaveKeyAsked = true; + return false; + } + agiIdle(); + key = dequeue(); + if (key == AGI_KEY_NONE) { + return false; + } + gAgi->vars[VAR_KEY] = (uint8_t)key; + return true; +} + + +// Parse a typed line into word groups for said(). +void inputParse(const char *line) { + char text[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. + 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++] = ' '; + } + space = true; + continue; + } + text[n++] = c; + space = false; + } + while (n > 0u && text[n - 1u] == ' ') { + n--; + } + text[n] = '\0'; + gAgi->wordCount = 0u; + gAgi->vars[VAR_BAD_WORD] = 0u; + if (n == 0u) { + return; + } + pos = 0u; + while (text[pos] != '\0' && gAgi->wordCount < AGI_MAX_WORDS) { + id = agiWordMatch(&gAgi->game, &text[pos], &len); + if (id == AGI_WORD_NONE) { + // Parsing stops at an unknown word, which stays in the list + // (any word and the rest of the line still match it) with + // 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]; + len++; + } + gAgi->wordText[gAgi->wordCount][len] = '\0'; + gAgi->wordIds[gAgi->wordCount] = AGI_WORD_NONE; + gAgi->wordCount++; + gAgi->vars[VAR_BAD_WORD] = gAgi->wordCount; + break; + } + if (id != AGI_WORD_IGNORE) { + memcpy(gAgi->wordText[gAgi->wordCount], &text[pos], len); + gAgi->wordText[gAgi->wordCount][len] = '\0'; + gAgi->wordIds[gAgi->wordCount] = id; + gAgi->wordCount++; + } + pos = (uint8_t)(pos + len); + if (text[pos] == ' ') { + pos++; + } + } + // A line of nothing but ignored words says nothing. + if (gAgi->wordCount == 0u) { + return; + } + gAgi->flags[FLAG_ENTERED] = 1u; + gAgi->flags[FLAG_SAID_OK] = 0u; +} + + +// Gather this frame's key presses into the queue as AGI key codes. +void inputPoll(void) { + int ch; + uint8_t i; + bool alt; + bool ctrl; + uint8_t dir; + + jlInputPoll(); + alt = jlKeyDown(KEY_LALT); + ctrl = jlKeyDown(KEY_LCTRL); + // A Ctrl or Alt chord is a command, not typing, whatever the port + // delivers as a character. + while ((ch = jlInputGetChar()) >= 0) { + if (!alt && !ctrl) { + enqueue((uint16_t)ch); + } + } + for (i = 0u; i < (uint8_t)(KEY_Z - KEY_A + 1); i++) { + if (jlKeyPressed((jlKeyE)(KEY_A + i))) { + if (alt) { + enqueue((uint16_t)((uint16_t)kLetterScan[i] << SCAN_SHIFT)); + } else if (ctrl) { + enqueue((uint16_t)(ASCII_CTRL_FIRST + i)); + } + } + } + for (i = 0u; i < FKEY_COUNT; i++) { + if (jlKeyPressed(kFunctionKeys[i])) { + enqueue((uint16_t)((uint16_t)(FKEY_SCAN_FIRST + i) << SCAN_SHIFT)); + } + } + if (jlKeyPressed(KEY_UP)) { + enqueue(AGI_KEY_UP); + } + if (jlKeyPressed(KEY_DOWN)) { + enqueue(AGI_KEY_DOWN); + } + if (jlKeyPressed(KEY_LEFT)) { + enqueue(AGI_KEY_LEFT); + } + if (jlKeyPressed(KEY_RIGHT)) { + enqueue(AGI_KEY_RIGHT); + } + if (jlJoystickConnected(JOYSTICK_0)) { + if (jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) { + enqueue(AGI_KEY_JOY_BUTTON_1); + } + if (jlJoyPressed(JOYSTICK_0, JOY_BUTTON_1)) { + enqueue(AGI_KEY_JOY_BUTTON_2); + } + dir = joystickDirection(); + if (dir != gJoyDir) { + gJoyDir = dir; + if (gAgi->playerControl) { + gAgi->objects[AGI_EGO].direction = dir; + gAgi->vars[VAR_EGO_DIR] = dir; + } + } + } +} + + +// The start of a cycle: hand every queued key to the game. +void inputProcess(void) { + uint16_t key; + + gKeyUnused = false; + gHaveKeyAsked = false; + gAgi->vars[VAR_KEY] = 0u; + while ((key = dequeue()) != AGI_KEY_NONE) { + handleKey(key); + } +} + + +void inputReset(void) { + gQueueHead = 0u; + gQueueTail = 0u; + gKeyUnused = false; +} + + +// Block until a key, keeping the game's clock and sound going. +uint16_t inputWaitKey(void) { + uint16_t key; + + for (;;) { + key = dequeue(); + if (key != AGI_KEY_NONE) { + return key; + } + agiIdle(); + } +} + + +// Wait for a message window to be dismissed: Enter or Escape, or after +// timeoutHalfSeconds half-seconds when that is not 0. Returns false on +// Escape. +bool inputWaitPrintClose(uint16_t timeoutHalfSeconds) { + uint32_t start; + uint16_t key; + + start = jlMillisElapsed(); + for (;;) { + key = dequeue(); + if (key == AGI_KEY_ENTER) { + return true; + } + if (key == AGI_KEY_ESCAPE) { + return false; + } + if (timeoutHalfSeconds != 0u && jlMillisElapsed() - start >= (uint32_t)timeoutHalfSeconds * HALF_SECOND_MS) { + return true; + } + agiIdle(); + } +} diff --git a/agiObj.c b/agiObj.c index 24629a7..fb518b5 100644 --- a/agiObj.c +++ b/agiObj.c @@ -1,363 +1,609 @@ -// AGI v2 animated-object table: state lifecycle + per-cycle ticks. +// Screen objects: cel cycling, motion and what stops it (AGI +// specification 4.2's object commands and 2.7's control lines). // -// The VM stores an AgiObjectT[16] inside AgiVmT; opcode handlers in -// agiVm.c mutate fields directly (set.view / position / step.size / -// etc.) and the host renders from the table each frame. This file -// owns the housekeeping the dispatcher would otherwise duplicate -// across many opcodes: -// -// * reset on construction and on NEW_ROOM -// * per-cycle cel cycling honoring start/stop.cycling, end.of.loop -// * per-cycle motion: normal / wander / follow.ego / move.obj -// -// Every algorithm here is a re-implementation against the published -// AGI v2 actor-cycle / actor-motion spec; no third-party source is -// referenced. +// Each interpreter cycle, before logic 0 runs, objPlanMotion turns the +// special motions (wander, follow.ego, move.obj) into a direction; after +// it, objUpdateAll erases the updated objects, steps their cels and moves +// them a step in their direction, then blits them again. A step is +// refused by an unconditional barrier (control 0), a conditional one +// (control 1) unless the object ignores blocks, water rules, the block +// rectangle and other objects' baselines; objects at fixed priority 15 +// ignore control lines. Leaving the screen or crossing the horizon stops +// the object at the edge and reports it in v2 (ego) or v4/v5. #include "agi.h" -#include "joey/core.h" -#include "joey/debug.h" - #include #include - -// AGI direction encoding. 0 means "no motion this cycle"; 1..8 -// circle clockwise starting at north. -// 1=N 2=NE 3=E 4=SE -// 5=S 6=SW 7=W 8=NW -#define AGI_DIR_NONE 0u -#define AGI_DIR_N 1u -#define AGI_DIR_NE 2u -#define AGI_DIR_E 3u -#define AGI_DIR_SE 4u -#define AGI_DIR_S 5u -#define AGI_DIR_SW 6u -#define AGI_DIR_W 7u -#define AGI_DIR_NW 8u -#define AGI_DIR_COUNT 9u - -#define WANDER_TICKS_MIN 4u -#define WANDER_TICKS_MAX 24u -#define MOVE_REACH_PX 2 +#include "joey/core.h" -// dx, dy per direction (subscript 0 unused; AGI directions are 1..8). -static const int8_t kDirDx[AGI_DIR_COUNT] = { 0, 0, +1, +1, +1, 0, -1, -1, -1 }; -static const int8_t kDirDy[AGI_DIR_COUNT] = { 0, -1, -1, 0, +1, +1, +1, 0, -1 }; +// 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 +#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. +// LOOP_KEEP leaves the loop alone. +#define LOOP_KEEP 0xFFu +#define LOOP_RIGHT 0u +#define LOOP_LEFT 1u +#define LOOP_DOWN 2u +#define LOOP_UP 3u +#define LOOPS_FOUR 4u +#define LOOPS_TWO 2u // ----- Prototypes ----- -static void advanceCycle(AgiVmT *vm, uint8_t objId); -static void advanceMotion(AgiVmT *vm, uint8_t objId); -static uint8_t celCount(const AgiVmT *vm, const AgiObjectT *obj); -static uint8_t directionToward(int16_t fromX, int16_t fromY, int16_t toX, int16_t toY); +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 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); + + +// ----- Tables ----- + +// A step in each direction. +static const int8_t kDirDx[AGI_DIR_COUNT] = { 0, 0, 1, 1, 1, 0, -1, -1, -1 }; +static const int8_t kDirDy[AGI_DIR_COUNT] = { 0, -1, -1, 0, 1, 1, 1, 0, -1 }; + +static const uint8_t kLoopFew[AGI_DIR_COUNT] = { LOOP_KEEP, LOOP_KEEP, LOOP_RIGHT, LOOP_RIGHT, LOOP_RIGHT, LOOP_KEEP, LOOP_LEFT, LOOP_LEFT, LOOP_LEFT }; +static const uint8_t kLoopFour[AGI_DIR_COUNT] = { LOOP_KEEP, LOOP_UP, LOOP_RIGHT, LOOP_RIGHT, LOOP_RIGHT, LOOP_DOWN, LOOP_LEFT, LOOP_LEFT, LOOP_LEFT }; + +// Direction for each (sign of dx, sign of dy), indexed [dy + 1][dx + 1]. +static const uint8_t kDirFromSigns[SIGN_STATES][SIGN_STATES] = { + { AGI_DIR_NW, AGI_DIR_N, AGI_DIR_NE }, + { AGI_DIR_W, AGI_DIR_NONE, AGI_DIR_E }, + { AGI_DIR_SW, AGI_DIR_S, AGI_DIR_SE } +}; + +// objFixPosition's spiral: left, down, right, up. +static const int8_t kSpiralDx[SPIRAL_LEGS] = { -1, 0, 1, 0 }; +static const int8_t kSpiralDy[SPIRAL_LEGS] = { 0, 1, 0, -1 }; // ----- Internal helpers (alphabetical) ----- -static void advanceCycle(AgiVmT *vm, uint8_t objId) { - AgiObjectT *obj; - uint8_t cels; - uint8_t next; +static void advanceCel(uint8_t n) { + AgiObjectT *o; + uint8_t last; - obj = &vm->objects[objId]; - if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) { + o = &gAgi->objects[n]; + if (o->view == NULL || o->loop >= o->view->loopCount) { return; } - if ((obj->flags & AGI_OBJ_FLAG_CYCLING) == 0u) { + last = o->view->loops[o->loop].celCount; + if (last == 0u) { return; } - // Erased objects don't cycle. KQ3 LOGIC.45 sparkle pattern depends - // on this: erase(1..4) at wizard-scene start leaves their CYCLING - // flag set (LOGIC.45 never explicitly stops it), but Sierra's - // interpreter freezes the cel until the next draw(). Without this - // gate, end.of.loop fires for invisible objs, sets flag 221..224, - // and the sparkle loop kicks back in on the wizard pic. - if ((obj->flags & AGI_OBJ_FLAG_DRAWN) == 0u) { - return; - } - // stop.update freezes the obj's displayed cel until start.update. - // Canonical AGI cycles cels internally regardless, but with no - // display refresh the visual stays put. Since our host renderer - // always paints from obj->cel, the easiest faithful approximation - // is to also stop advancing cel while !UPDATING -- otherwise - // poses cycle visibly (e.g. Manannan's body in room 46, which is - // stop.update'd at init and only released to start.update at a - // specific state, would otherwise wave its hand continuously). - if ((obj->flags & AGI_OBJ_FLAG_UPDATING) == 0u) { - return; - } - if (obj->cycleTime == 0u) { - return; - } - obj->cycleTick++; - if (obj->cycleTick < obj->cycleTime) { - return; - } - obj->cycleTick = 0u; - - cels = celCount(vm, obj); - if (cels <= 1u) { - return; - } - - switch (obj->cycleMode) { + last--; + switch (o->cycleMode) { case AGI_CYCLE_NORMAL: - next = (uint8_t)((obj->cel + 1u) % cels); - obj->cel = next; + o->cel = (o->cel >= last) ? 0u : (uint8_t)(o->cel + 1u); break; case AGI_CYCLE_REVERSE: - obj->cel = (obj->cel == 0u) ? (uint8_t)(cels - 1u) : (uint8_t)(obj->cel - 1u); + o->cel = (o->cel == 0u || o->cel > last) ? last : (uint8_t)(o->cel - 1u); break; case AGI_CYCLE_END_LOOP: - if (obj->cel + 1u >= cels) { - // reached last frame, fire end-of-loop - obj->flags &= (uint8_t)~AGI_OBJ_FLAG_CYCLING; - if (obj->endOfLoopFlag != 0u) { - vm->flags[obj->endOfLoopFlag] = 1u; - } - } else { - obj->cel++; + case AGI_CYCLE_REVERSE_LOOP: + // The flag is set as the end cel is reached. + if (o->cycleMode == AGI_CYCLE_END_LOOP && o->cel < last) { + o->cel++; + } else if (o->cycleMode == AGI_CYCLE_REVERSE_LOOP && o->cel > 0u) { + o->cel--; } - break; - case AGI_CYCLE_REV_LOOP: - if (obj->cel == 0u) { - obj->flags &= (uint8_t)~AGI_OBJ_FLAG_CYCLING; - if (obj->endOfLoopFlag != 0u) { - vm->flags[obj->endOfLoopFlag] = 1u; - } - } else { - obj->cel--; + if ((o->cycleMode == AGI_CYCLE_END_LOOP) ? (o->cel >= last) : (o->cel == 0u)) { + gAgi->flags[o->loopFlag] = 1u; + o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_CYCLING); + o->cycleMode = AGI_CYCLE_NORMAL; } break; default: break; } + objSetCel(n, o->cel); } -static void advanceMotion(AgiVmT *vm, uint8_t objId) { - AgiObjectT *obj; - int16_t newX; - int16_t newY; - uint8_t dir; - int8_t dx; - int8_t dy; +// Another object's baseline in the way: the two baselines overlap (or +// touch) and are on the same line, or this step crosses the other's. +static bool blockedByObject(uint8_t n, int16_t x, int16_t y) { + const AgiObjectT *o; + const AgiObjectT *other; + uint8_t i; - obj = &vm->objects[objId]; - if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) { - return; + o = &gAgi->objects[n]; + if ((o->flags & OBJ_IGNORE_OBJS) != 0u) { + return false; } - if ((obj->flags & AGI_OBJ_FLAG_DRAWN) == 0u) { - return; - } - if (obj->stepSize == 0u) { - return; - } - if (obj->stepTime == 0u) { - return; - } - obj->stepTick++; - if (obj->stepTick < obj->stepTime) { - return; - } - obj->stepTick = 0u; - - switch (obj->motionType) { - case AGI_MOTION_WANDER: - if (obj->wanderTick == 0u) { - obj->direction = (uint8_t)(jlRandomRange((uint16_t)AGI_DIR_COUNT)); - obj->wanderTick = (uint8_t)(WANDER_TICKS_MIN + jlRandomRange((uint16_t)(WANDER_TICKS_MAX - WANDER_TICKS_MIN))); - } else { - obj->wanderTick--; - } - break; - case AGI_MOTION_FOLLOW_EGO: - obj->direction = directionToward(obj->x, obj->y, vm->objects[0].x, vm->objects[0].y); - break; - case AGI_MOTION_MOVE_OBJ: { - int16_t reachDx; - int16_t reachDy; - - reachDx = (int16_t)(obj->targetX - obj->x); - reachDy = (int16_t)(obj->targetY - obj->y); - if (reachDx < 0) { - reachDx = (int16_t)(-reachDx); - } - if (reachDy < 0) { - reachDy = (int16_t)(-reachDy); - } - if (reachDx <= MOVE_REACH_PX && reachDy <= MOVE_REACH_PX) { - obj->x = obj->targetX; - obj->y = obj->targetY; - obj->direction = AGI_DIR_NONE; - obj->motionType = AGI_MOTION_NORMAL; - obj->stepSize = obj->moveStepBackup; - if (obj->moveDoneFlag != 0u) { - vm->flags[obj->moveDoneFlag] = 1u; - } - return; - } - obj->direction = directionToward(obj->x, obj->y, obj->targetX, obj->targetY); - break; + for (i = 0u; i < gAgi->objectCount; i++) { + other = &gAgi->objects[i]; + if (i == n || (other->flags & (OBJ_ANIMATED | OBJ_DRAWN)) != (OBJ_ANIMATED | OBJ_DRAWN) || + (other->flags & OBJ_IGNORE_OBJS) != 0u) { + continue; + } + if (x + (int16_t)o->width < other->x || other->x + (int16_t)other->width < x) { + continue; + } + if (y == other->y || (o->y < other->y && y > other->y) || (o->y > other->y && y < other->y)) { + return true; } - case AGI_MOTION_NORMAL: - default: - // Direction is whatever the script (or var 6 for ego) set. - break; } + return false; +} - dir = obj->direction; - if (dir == AGI_DIR_NONE || dir >= AGI_DIR_COUNT) { + +// Keep a position on the screen and below the horizon; returns the edge +// it ran into, if any. +static uint8_t clampToScreen(AgiObjectT *o, int16_t *x, int16_t *y) { + uint8_t edge; + + edge = AGI_EDGE_NONE; + if (*x < 0) { + *x = 0; + edge = AGI_EDGE_LEFT; + } else if (*x + (int16_t)o->width > AGI_WIDTH) { + *x = (int16_t)(AGI_WIDTH - (int16_t)o->width); + edge = AGI_EDGE_RIGHT; + } + if (*y > AGI_MAX_Y) { + *y = AGI_MAX_Y; + edge = AGI_EDGE_BOTTOM; + } else if ((o->flags & OBJ_IGNORE_HORIZON) == 0u && *y <= (int16_t)gAgi->horizon) { + *y = (int16_t)(gAgi->horizon + 1u); + edge = AGI_EDGE_TOP; + } else if (*y - (int16_t)o->height + 1 < 0) { + *y = (int16_t)(o->height - 1u); + edge = AGI_EDGE_TOP; + } + return edge; +} + + +// 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. +static bool legalPosition(uint8_t n, int16_t x, int16_t y) { + const AgiObjectT *o; + int16_t i; + uint8_t c; + bool anyWater; + bool allWater; + bool signal; + bool insideNow; + bool insideNew; + + o = &gAgi->objects[n]; + anyWater = false; + allWater = true; + signal = false; + if (!((o->flags & OBJ_FIX_PRIORITY) != 0u && o->priority == AGI_PRIORITY_MAX)) { + for (i = 0; i < (int16_t)o->width; i++) { + c = gfxControlAt((int16_t)(x + i), y); + if (c == AGI_CTL_BARRIER) { + return false; + } + if (c == AGI_CTL_BLOCK && (o->flags & OBJ_IGNORE_BLOCKS) == 0u) { + return false; + } + if (c == AGI_CTL_WATER) { + anyWater = true; + } else { + allWater = false; + } + if (c == AGI_CTL_SIGNAL) { + signal = true; + } + } + if ((o->flags & OBJ_ON_WATER) != 0u && !allWater) { + return false; + } + if ((o->flags & OBJ_ON_LAND) != 0u && anyWater) { + return false; + } + } else { + allWater = false; + } + if (gAgi->blockActive && (o->flags & OBJ_IGNORE_BLOCKS) == 0u) { + insideNow = o->x >= gAgi->blockX1 && o->x <= gAgi->blockX2 && o->y >= gAgi->blockY1 && o->y <= gAgi->blockY2; + insideNew = x >= gAgi->blockX1 && x <= gAgi->blockX2 && y >= gAgi->blockY1 && y <= gAgi->blockY2; + if (insideNow != insideNew) { + return false; + } + } + if (blockedByObject(n, x, y)) { + return false; + } + if (n == AGI_EGO) { + gAgi->flags[FLAG_EGO_WATER] = allWater ? 1u : 0u; + gAgi->flags[FLAG_EGO_SIGNAL] = signal ? 1u : 0u; + } + return true; +} + + +// One step in the object's direction, if the way is clear. +static void moveObject(uint8_t n) { + AgiObjectT *o; + int16_t x; + int16_t y; + uint8_t edge; + + o = &gAgi->objects[n]; + if (o->direction == AGI_DIR_NONE || o->direction >= AGI_DIR_COUNT) { return; } - dx = kDirDx[dir]; - dy = kDirDy[dir]; - newX = (int16_t)(obj->x + (int16_t)((int16_t)dx * (int16_t)obj->stepSize)); - newY = (int16_t)(obj->y + (int16_t)((int16_t)dy * (int16_t)obj->stepSize)); - - // Clamp to AGI's canonical 160x168 picture window. The horizon - // applies only to objects observing it. - if (newX < 0) { - newX = 0; + x = (int16_t)(o->x + kDirDx[o->direction] * (int16_t)o->stepSize); + y = (int16_t)(o->y + kDirDy[o->direction] * (int16_t)o->stepSize); + edge = clampToScreen(o, &x, &y); + 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)); } - if (newX > (int16_t)(AGI_PIC_WIDTH - 1)) { - newX = (int16_t)(AGI_PIC_WIDTH - 1); + // Ego's edge is current: a step that touches none clears it. + if (edge != AGI_EDGE_NONE) { + reportEdge(n, edge); + } else if (n == AGI_EGO) { + gAgi->vars[VAR_EGO_EDGE] = AGI_EDGE_NONE; } - if (newY > (int16_t)(AGI_PIC_HEIGHT - 1)) { - newY = (int16_t)(AGI_PIC_HEIGHT - 1); - } - if ((obj->flags & AGI_OBJ_FLAG_HORIZON_IGN) == 0u && newY < (int16_t)vm->horizon) { - newY = (int16_t)vm->horizon; - } - if (newY < 0) { - newY = 0; - } - obj->x = newX; - obj->y = newY; } -// Without a view-id-to-loop-cel-count map in the VM, fall back to an -// Asks the host for the actual cel count of obj's view+loop via -// the viewCelCount callback. Falls back to AGI_MAX_CELS_PER_LOOP -// only when no callback is wired (legacy host) -- but cycling -// is broken in that mode because end.of.loop won't fire at the -// correct cel. A 0 return from the callback means "view not -// loaded yet"; we report 0 and the caller's `cels <= 1u` guard -// pauses cycling until the load completes. -static uint8_t celCount(const AgiVmT *vm, const AgiObjectT *obj) { - if (vm->callbacks.viewCelCount != NULL) { - return vm->callbacks.viewCelCount(vm->callbacks.ctx, obj->viewId, obj->loop); - } - return AGI_MAX_CELS_PER_LOOP; +// The object's cel is wholly on the screen, below the horizon unless it +// ignores it. +static bool onScreen(const AgiObjectT *o, int16_t x, int16_t y) { + return x >= 0 && x + (int16_t)o->width <= AGI_WIDTH && y <= AGI_MAX_Y && y - (int16_t)o->height + 1 >= 0 && + ((o->flags & OBJ_IGNORE_HORIZON) != 0u || y > (int16_t)gAgi->horizon); } -// 8-direction snap by quadrant + axis-dominance. -static uint8_t directionToward(int16_t fromX, int16_t fromY, int16_t toX, int16_t toY) { - int16_t dx; - int16_t dy; - int16_t ax; - int16_t ay; +static void reportEdge(uint8_t n, uint8_t edge) { + if (n == AGI_EGO) { + gAgi->vars[VAR_EGO_EDGE] = edge; + } else { + gAgi->vars[VAR_OBJ_EDGE_OBJ] = n; + gAgi->vars[VAR_OBJ_EDGE] = edge; + } +} - dx = (int16_t)(toX - fromX); - dy = (int16_t)(toY - fromY); - if (dx == 0 && dy == 0) { - return AGI_DIR_NONE; + +static void selectLoop(AgiObjectT *o) { + uint8_t loop; + + if ((o->flags & OBJ_FIX_LOOP) != 0u || o->view == NULL || o->view->loopCount < LOOPS_TWO) { + return; } - ax = (dx < 0) ? (int16_t)(-dx) : dx; - ay = (dy < 0) ? (int16_t)(-dy) : dy; - if (ax > ay * 2) { - return (dx > 0) ? AGI_DIR_E : AGI_DIR_W; + loop = (o->view->loopCount >= LOOPS_FOUR) ? kLoopFour[o->direction] : kLoopFew[o->direction]; + if (loop != LOOP_KEEP && loop != o->loop) { + objSetLoop((uint8_t)(o - gAgi->objects), loop); } - if (ay > ax * 2) { - return (dy > 0) ? AGI_DIR_S : AGI_DIR_N; +} + + +// A special motion is over: set its flag, stop, and give back what it +// took (the step size, and for ego the keyboard). +static void stopSpecialMotion(uint8_t n) { + AgiObjectT *o; + + o = &gAgi->objects[n]; + if (o->motion == AGI_MOTION_MOVE) { + o->stepSize = o->savedStepSize; + if (n == AGI_EGO) { + gAgi->playerControl = true; + } } - if (dx > 0) { - return (dy > 0) ? AGI_DIR_SE : AGI_DIR_NE; + gAgi->flags[o->motionFlag] = 1u; + o->motion = AGI_MOTION_NORMAL; + o->direction = AGI_DIR_NONE; + if (n == AGI_EGO) { + gAgi->vars[VAR_EGO_DIR] = AGI_DIR_NONE; } - return (dy > 0) ? AGI_DIR_SW : AGI_DIR_NW; } // ----- Public API (alphabetical) ----- -void agiObjReset(AgiObjectT *obj) { - memset(obj, 0, sizeof(*obj)); - obj->stepSize = 1u; - obj->stepTime = 1u; - obj->cycleTime = 1u; - obj->priority = 0u; // 0 = "auto-Y-band" (canonical AGI) - obj->direction = AGI_DIR_NONE; - obj->cycleMode = AGI_CYCLE_NORMAL; - obj->motionType = AGI_MOTION_NORMAL; - obj->endOfLoopFlag = 0u; - obj->moveDoneFlag = 0u; - obj->followDoneFlag = 0u; -} +// animate.obj: bring an object under the interpreter's control. Its +// step and cycle settings are left as they are: new.room (unanimate.all) +// is what sets them back to the defaults, so before the first new.room +// they are all 0 (measured: such objects neither cycle nor move). +void objAnimate(uint8_t n) { + AgiObjectT *o; - -void agiObjResetAll(AgiVmT *vm) { - uint8_t i; - - for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { - agiObjReset(&vm->objects[i]); + o = &gAgi->objects[n]; + if ((o->flags & OBJ_ANIMATED) == 0u) { + o->flags = (uint16_t)((o->flags & OBJ_SKIP_STEP) | OBJ_ANIMATED | OBJ_UPDATE | OBJ_CYCLING); } } -// Called by VM core after each VM cycle returns to the host (or by -// the host directly each game-loop cycle). Walks the object table -// and advances cycling + motion. Also polls the host for sound -// completion so the sound-done flag fires on the natural-end of -// playback rather than a pre-computed deadline. -void agiVmTickAnimation(AgiVmT *vm) { +// The objects as the interpreter starts: all zero, each yet to make its +// first step. +void objClearAll(void) { uint8_t i; - // Sound poll: fire done-flags on the host's playback->silent - // edge. Runs every VM cycle so OP_IF wait loops see the flag - // within one cycle of actual audio end. - if (vm->soundPlaying && vm->callbacks.isPlayingSound != NULL) { - if (!vm->callbacks.isPlayingSound(vm->callbacks.ctx)) { - jlLogF("sound-done flag fires: flag=%u (v11=%u)", - (unsigned)vm->soundDoneFlag, (unsigned)vm->vars[11]); - jlLogFlush(); - if (vm->soundDoneFlag != 0u) { - vm->flags[vm->soundDoneFlag] = 1u; + memset(gAgi->objects, 0, sizeof(gAgi->objects)); + for (i = 0u; i < AGI_MAX_OBJECTS; i++) { + gAgi->objects[i].flags = OBJ_SKIP_STEP; + } +} + + +// The direction from one point toward another, treating offsets smaller +// than `slack` as none (measured: move.obj with step 2 goes the last 2 +// pixels, with step 3 stops 2 short). +uint8_t objDirectionTo(int16_t fromX, int16_t fromY, int16_t toX, int16_t toY, uint8_t slack) { + int16_t dx; + int16_t dy; + int16_t s; + uint8_t sx; + uint8_t sy; + + s = (int16_t)((slack == 0u) ? 1u : slack); + dx = (int16_t)(toX - fromX); + dy = (int16_t)(toY - fromY); + sx = (dx <= -s) ? 0u : (dx >= s) ? 2u : 1u; + sy = (dy <= -s) ? 0u : (dy >= s) ? 2u : 1u; + return kDirFromSigns[sy][sx]; +} + + +// Put an object somewhere it may stand (measured from Sierra's +// interpreter): below the horizon, then, if it may not stand there, +// spiralling out a pixel at a time -- left 1, down 1, right 2, up 2, +// left 3, ... -- to the first place on the screen clear of barriers and +// other objects. +void objFixPosition(uint8_t n) { + AgiObjectT *o; + int16_t x; + int16_t y; + uint16_t len; + uint16_t step; + uint8_t leg; + + o = &gAgi->objects[n]; + x = o->x; + y = o->y; + if ((o->flags & OBJ_IGNORE_HORIZON) == 0u && y <= (int16_t)gAgi->horizon) { + y = (int16_t)(gAgi->horizon + 1u); + } + leg = 0u; + len = 1u; + while (!(onScreen(o, x, y) && legalPosition(n, x, y)) && len <= SPIRAL_MAX) { + for (step = 0u; step < len; step++) { + x = (int16_t)(x + kSpiralDx[leg]); + y = (int16_t)(y + kSpiralDy[leg]); + if (onScreen(o, x, y) && legalPosition(n, x, y)) { + break; } - vm->flags[9] = 1u; - vm->soundPlaying = false; + } + if (step < len) { + break; + } + // The legs lengthen every second turn. + leg = (uint8_t)((leg + 1u) % SPIRAL_LEGS); + if ((leg & 1u) == 0u) { + len++; } } + o->x = x; + o->y = y; +} - for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { - AgiObjectT *obj; - obj = &vm->objects[i]; - if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) { +// move.obj: head for (x, y) at `step` pixels a step (0 keeps the step +// size); flag is set on arrival. +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; + 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) { + gAgi->playerControl = false; + gAgi->vars[VAR_EGO_DIR] = o->direction; + } + if (o->direction == AGI_DIR_NONE) { + stopSpecialMotion(n); + } +} + + +// Before logic 0: work out where wandering, following and moving objects +// head this cycle. +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)) { continue; } - // Snapshot current state so the host renderer can restore the - // last frame's pixels under each obj before re-drawing this - // frame. Done before the tick so prevX/prevY reflect what was - // actually painted last frame. - obj->prevX = obj->x; - obj->prevY = obj->y; - obj->prevLoop = obj->loop; - obj->prevCel = obj->cel; - obj->prevView = obj->viewId; - - advanceCycle(vm, i); - advanceMotion(vm, i); - } - // Mirror ego state into AGI's well-known engine vars so game - // scripts that probe v6 (ego dir) / current.view / get.posn - // see the right values without a per-opcode getter call. - if ((vm->objects[0].flags & AGI_OBJ_FLAG_ANIMATED) != 0u) { - vm->vars[6] = vm->objects[0].direction; + 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--; + } + 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); + } + break; + case AGI_MOTION_MOVE: + o->direction = objDirectionTo(o->x, o->y, o->targetX, o->targetY, o->stepSize); + if (o->direction == AGI_DIR_NONE) { + stopSpecialMotion(i); + } + break; + default: + break; + } + if (i == AGI_EGO) { + gAgi->vars[VAR_EGO_DIR] = o->direction; + } } } + + +void objSetCel(uint8_t n, uint8_t cel) { + AgiObjectT *o; + const AgiCelT *c; + bool mirror; + + o = &gAgi->objects[n]; + o->cel = cel; + if (o->view == NULL || o->loop >= o->view->loopCount) { + return; + } + // A cel (or loop) the new view or loop lacks becomes 0 (measured). + if (cel >= o->view->loops[o->loop].celCount) { + o->cel = 0u; + } + c = agiViewCel(o->view, o->loop, o->cel, &mirror); + if (c != NULL) { + o->width = c->width; + o->height = c->height; + } +} + + +void objSetLoop(uint8_t n, uint8_t loop) { + AgiObjectT *o; + + o = &gAgi->objects[n]; + if (o->view == NULL) { + o->loop = loop; + return; + } + o->loop = (loop >= o->view->loopCount) ? 0u : loop; + objSetCel(n, o->cel); +} + + +bool objSetView(uint8_t n, uint8_t viewId) { + AgiObjectT *o; + const AgiViewT *view; + + view = agiViewGet(viewId); + if (view == NULL) { + return false; + } + o = &gAgi->objects[n]; + o->view = view; + o->viewId = viewId; + if (n == AGI_EGO) { + gAgi->vars[VAR_EGO_VIEW] = viewId; + } + objSetLoop(n, o->loop); + return true; +} + + +// unanimate.all (and new.room): every object off the screen and out of +// the interpreter's hands, with the default step and cycle settings. Its +// view, loop, cel and position stay (new.room places ego from them). +void objUnanimateAll(void) { + uint8_t i; + AgiObjectT *o; + + gfxEraseAll(); + for (i = 0u; i < AGI_MAX_OBJECTS; i++) { + o = &gAgi->objects[i]; + o->flags = OBJ_SKIP_STEP; + o->direction = AGI_DIR_NONE; + o->motion = AGI_MOTION_NORMAL; + o->cycleMode = AGI_CYCLE_NORMAL; + o->stepSize = STEP_DEFAULT; + o->stepTime = STEP_DEFAULT; + o->stepCount = STEP_DEFAULT; + o->cycleTime = STEP_DEFAULT; + o->cycleCount = STEP_DEFAULT; + } + gfxPresentDirty(); +} + + +// After logic 0: step every updated object's cel and move it, then draw +// them all again. +void objUpdateAll(void) { + uint8_t i; + AgiObjectT *o; + + gfxEraseUpdating(); + 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)) { + continue; + } + selectLoop(o); + // A cycle count of 0 (an object never given a cycle time) never + // comes due; a step count of 0 is due every cycle. + if ((o->flags & OBJ_CYCLING) != 0u && o->cycleCount != 0u) { + o->cycleCount--; + if (o->cycleCount == 0u) { + o->cycleCount = o->cycleTime; + if ((o->flags & OBJ_SKIP_CYCLE) != 0u) { + o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_SKIP_CYCLE); + } else { + advanceCel(i); + } + } + } + if (o->stepCount > 0u) { + o->stepCount--; + } + 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); + } + } + } + gfxRedrawUpdating(); +} diff --git a/agiPic.c b/agiPic.c index ae92566..5f23a99 100644 --- a/agiPic.c +++ b/agiPic.c @@ -1,256 +1,255 @@ -// AGI v2 PIC (picture / background) decoder. +// AGI v2 PICTURE decoding (AGI specification chapter 7). // -// Paints to two private 160x168 chunky 8bpp buffers (visual + priority). -// The blit step doubles each AGI source column into two stage columns -// so the natural 160x168 internal coordinate system can be used by the -// drawing primitives, and the 320-wide stage receives the canonical -// AGI look. -// -// All algorithms here -- line interpolation, corner drawing, scanline -// flood fill, pen plotting, opcode dispatch -- are re-implementations -// written against the published AGI v2 picture-stream specification. -// No third-party source is referenced. +// A picture is a byte stream of drawing actions, 0xF0..0xFA, each followed +// by argument bytes below 0xF0, ending at 0xFF. It draws into the visual +// buffer and the priority buffer (160x168, a byte per AGI pixel); each +// action draws to whichever of the two is enabled. #include "agi.h" -#include "joey/core.h" -#include "joey/draw.h" -#include "surfaceInternal.h" - #include -#include #include -// PIC opcodes. Anything < 0xF0 is an argument byte; >= 0xF0 ends the -// current opcode's argument list. -#define OP_FIRST 0xF0u -#define OP_SET_PIC_COLOR 0xF0u -#define OP_DISABLE_PIC 0xF1u -#define OP_SET_PRI_COLOR 0xF2u -#define OP_DISABLE_PRI 0xF3u -#define OP_Y_CORNER 0xF4u -#define OP_X_CORNER 0xF5u -#define OP_ABS_LINE 0xF6u -#define OP_REL_LINE 0xF7u -#define OP_FILL 0xF8u -#define OP_SET_PEN 0xF9u -#define OP_PLOT_PEN 0xFAu -#define OP_END 0xFFu +#define PIC_FIRST_ACTION 0xF0u +#define PIC_SET_COLOR 0xF0u +#define PIC_NO_COLOR 0xF1u +#define PIC_SET_PRIORITY 0xF2u +#define PIC_NO_PRIORITY 0xF3u +#define PIC_Y_CORNER 0xF4u +#define PIC_X_CORNER 0xF5u +#define PIC_ABS_LINE 0xF6u +#define PIC_REL_LINE 0xF7u +#define PIC_FILL 0xF8u +#define PIC_SET_PEN 0xF9u +#define PIC_PLOT_PEN 0xFAu +#define PIC_END 0xFFu -// Pen control byte bits (set via OP_SET_PEN, consumed by OP_PLOT_PEN). -#define PEN_PATTERN_BIT 0x20u +// Relative line displacement byte: bit 7 the x sign, bits 4-6 x, bit 3 +// the y sign, bits 0-2 y. +#define REL_X_SIGN 0x80u +#define REL_X_SHIFT 4u +#define REL_Y_SIGN 0x08u +#define REL_MAG_MASK 0x07u -// Scanline-fill seed stack. Each fill is reentrant-free, so a single -// static stack serves both the visual and priority passes. The bound -// is a safe over-estimate of typical PIC fill complexity; KQ3 PICs -// don't approach it. -#define FILL_STACK_MAX 512 +// Pen byte: bit 5 splatter, bit 4 rectangle, bits 0-2 the size. +#define PEN_SPLATTER 0x20u +#define PEN_RECTANGLE 0x10u +#define PEN_SIZE_MASK 0x07u +#define PEN_SIZES 8u +#define PEN_MAX_ROWS 15u +#define PEN_ROW_MSB 0x80u +#define PEN_ROW_FULL 0xFFu +#define TEXTURE_BITS_LAST 254u +#define TEXTURE_CODE_SHIFT 1u +#define BIT_BYTE_SHIFT 3u +#define BIT_INDEX_MASK 7u + +// Flood fill: a scanline fill with an explicit stack of row spans. +#define FILL_STACK_MAX 1024u typedef struct { - uint8_t visualEnabled; - uint8_t priorityEnabled; - uint8_t visualColor; - uint8_t priorityColor; - uint8_t penPattern; -} PicStateT; - - -// AGI 16-color CGA-derived palette. Entry 6's (R=A, G=5, B=0) keeps the -// classic CGA "brown fix" -- pure (R=A, G=A, B=0) would read as a vivid -// yellow-green on the host, which is wrong for AGI's intended look. -const uint16_t kAgiPalette[16] = { - 0x000u, 0x00Au, 0x0A0u, 0x0AAu, - 0xA00u, 0xA0Au, 0xA50u, 0xAAAu, - 0x555u, 0x55Fu, 0x5F5u, 0x5FFu, - 0xF55u, 0xF5Fu, 0xFF5u, 0xFFFu -}; + uint8_t *visual; + uint8_t *priority; + bool visualOn; + bool priorityOn; + uint8_t color; + uint8_t priorityColor; + uint8_t pen; +} PicDrawT; // ----- Prototypes ----- -static void drawCorner(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos, bool yFirst); -static void drawLine(AgiPicT *pic, const PicStateT *state, int16_t x0, int16_t y0, int16_t x1, int16_t y1); -static void fillPlane(uint8_t *plane, int16_t x, int16_t y, uint8_t bgValue, uint8_t newValue); -static bool isOpcode(uint8_t byte); -static void opAbsLine(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos); -static void opFill(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos); -static void opPlotPen(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos); -static void opRelLine(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos); -static void plot(AgiPicT *pic, const PicStateT *state, int16_t x, int16_t y); +static void drawLine(const PicDrawT *d, int16_t x0, int16_t y0, int16_t x1, int16_t y1); +static void fill(const PicDrawT *d, int16_t x, int16_t y); +static bool fillable(const PicDrawT *d, uint16_t idx); +static uint16_t opAbsLine(const PicDrawT *d, const uint8_t *data, uint16_t length, uint16_t pos); +static uint16_t opCorner(const PicDrawT *d, const uint8_t *data, uint16_t length, uint16_t pos, bool yFirst); +static uint16_t opPlotPen(const PicDrawT *d, const uint8_t *data, uint16_t length, uint16_t pos); +static uint16_t opRelLine(const PicDrawT *d, const uint8_t *data, uint16_t length, uint16_t pos); +static void plot(const PicDrawT *d, int16_t x, int16_t y); +static void plotPen(const PicDrawT *d, int16_t x, int16_t y, uint8_t texture); + + +// ----- Tables ----- + +// The circle pens of each size, a row per byte with the leftmost pixel in +// bit 7: size n is n+1 wide and 2n+1 tall (specification 7.2). +static const uint8_t kCircleRows[PEN_SIZES][PEN_MAX_ROWS] = { + { 0x80 }, + { 0xC0, 0xC0, 0xC0 }, + { 0x40, 0xE0, 0xE0, 0xE0, 0x40 }, + { 0x60, 0x60, 0xF0, 0xF0, 0xF0, 0x60, 0x60 }, + { 0x20, 0x70, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0x70, 0x20 }, + { 0x30, 0x78, 0x78, 0x78, 0xFC, 0xFC, 0xFC, 0x78, 0x78, 0x78, 0x30 }, + { 0x38, 0x7C, 0x7C, 0x7C, 0xFE, 0xFE, 0xFE, 0xFE, 0xFE, 0x7C, 0x7C, 0x7C, 0x38 }, + { 0x18, 0x3C, 0x7E, 0x7E, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7E, 0x7E, 0x7E, 0x3C, 0x18 } +}; + +// Splatter pens: 256 bits of texture and each texture number's starting +// bit within them (specification 7.2). +static const uint8_t kTextureBits[] = { + 0x20, 0x94, 0x02, 0x24, 0x90, 0x82, 0xa4, 0xa2, + 0x82, 0x09, 0x0a, 0x22, 0x12, 0x10, 0x42, 0x14, + 0x91, 0x4a, 0x91, 0x11, 0x08, 0x12, 0x25, 0x10, + 0x22, 0xa8, 0x14, 0x24, 0x00, 0x50, 0x24, 0x04 +}; + +static const uint8_t kTextureStart[] = { + 0x00, 0x18, 0x30, 0xc4, 0xdc, 0x65, 0xeb, 0x48, + 0x60, 0xbd, 0x89, 0x04, 0x0a, 0xf4, 0x7d, 0x6d, + 0x85, 0xb0, 0x8e, 0x95, 0x1f, 0x22, 0x0d, 0xdf, + 0x2a, 0x78, 0xd5, 0x73, 0x1c, 0xb4, 0x40, 0xa1, + 0xb9, 0x3c, 0xca, 0x58, 0x92, 0x34, 0xcc, 0xce, + 0xd7, 0x42, 0x90, 0x0f, 0x8b, 0x7f, 0x32, 0xed, + 0x5c, 0x9d, 0xc8, 0x99, 0xad, 0x4e, 0x56, 0xa6, + 0xf7, 0x68, 0xb7, 0x25, 0x82, 0x37, 0x3a, 0x51, + 0x69, 0x26, 0x38, 0x52, 0x9e, 0x9a, 0x4f, 0xa7, + 0x43, 0x10, 0x80, 0xee, 0x3d, 0x59, 0x35, 0xcf, + 0x79, 0x74, 0xb5, 0xa2, 0xb1, 0x96, 0x23, 0xe0, + 0xbe, 0x05, 0xf5, 0x6e, 0x19, 0xc5, 0x66, 0x49, + 0xf0, 0xd1, 0x54, 0xa9, 0x70, 0x4b, 0xa4, 0xe2, + 0xe6, 0xe5, 0xab, 0xe4, 0xd2, 0xaa, 0x4c, 0xe3, + 0x06, 0x6f, 0xc6, 0x4a, 0x75, 0xa3, 0x97, 0xe1 +}; + + +// ----- Module state ----- + +// fill's seed stack, in byte coordinates (the picture is 160x168) to keep +// it small in the IIgs's bank 0. +static uint8_t gFillX[FILL_STACK_MAX]; +static uint8_t gFillY[FILL_STACK_MAX]; // ----- Internal helpers (alphabetical) ----- -static void drawCorner(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos, bool yFirst) { - uint16_t p; - int16_t curX; - int16_t curY; - bool readY; +// A line steps a pixel at a time along its longer side; the shorter side +// moves when the error it carries (starting at half the longer side's +// length, growing by the shorter side's each step) reaches the longer +// side's length -- the exact fraction rounded to nearest, an exact half +// rounding away from the start, so the last of the longer side + 1 points +// is the end point. Sierra's interpreter carries the error in a byte +// (AGI_QUIRK_LINE_BYTE_ERROR): when it passes 255 the step is lost, and a +// line long on both sides ends short of its end point (measured on 2.435: +// (0,0) to (159,167) stalls two rows in ten and ends at (134,167)). +static void drawLine(const PicDrawT *d, int16_t x0, int16_t y0, int16_t x1, int16_t y1) { + int16_t adx; + int16_t ady; + int16_t sx; + int16_t sy; + int16_t major; + int16_t minorLen; + int16_t majorX; + int16_t majorY; + int16_t minorX; + int16_t minorY; + int16_t i; + uint16_t err; + bool byteError; - p = *pos; - if (p + 1 >= length) { - *pos = length; - return; - } - curX = (int16_t)data[p++]; - curY = (int16_t)data[p++]; - plot(pic, state, curX, curY); - - readY = yFirst; - while (p < length && !isOpcode(data[p])) { - if (readY) { - int16_t newY = (int16_t)data[p++]; - drawLine(pic, state, curX, curY, curX, newY); - curY = newY; - } else { - int16_t newX = (int16_t)data[p++]; - drawLine(pic, state, curX, curY, newX, curY); - curX = newX; - } - readY = !readY; - } - *pos = p; -} - - -// Documented AGI line algorithm. Major axis steps in unit increments; -// the minor axis is linearly interpolated with round-half-away-from- -// zero. All math stays in 16-bit -- AGI coordinates are 8-bit so the -// worst product (dy * i = 167 * 167 = 27889) fits in int16, and the -// 65816 build avoids long-arithmetic helpers entirely. -static void drawLine(AgiPicT *pic, const PicStateT *state, int16_t x0, int16_t y0, int16_t x1, int16_t y1) { - int16_t dx; - int16_t dy; - int16_t absDx; - int16_t absDy; - int16_t steps; - int16_t i; - int16_t half; - int16_t px; - int16_t py; - int16_t sx; - int16_t sy; - - dx = (int16_t)(x1 - x0); - dy = (int16_t)(y1 - y0); - if (dx == 0 && dy == 0) { - plot(pic, state, x0, y0); - return; - } - - absDx = (int16_t)((dx >= 0) ? dx : -dx); - absDy = (int16_t)((dy >= 0) ? dy : -dy); - sx = (int16_t)((dx >= 0) ? 1 : -1); - sy = (int16_t)((dy >= 0) ? 1 : -1); - - if (absDx >= absDy) { - steps = absDx; - half = (int16_t)(steps / 2); - for (i = 0; i <= steps; i++) { - px = (int16_t)(x0 + sx * i); - py = (int16_t)(y0 + (dy * i + sy * half) / steps); - plot(pic, state, px, py); - } + adx = (int16_t)((x1 < x0) ? x0 - x1 : x1 - x0); + ady = (int16_t)((y1 < y0) ? y0 - y1 : y1 - y0); + sx = (int16_t)((x1 < x0) ? -1 : 1); + sy = (int16_t)((y1 < y0) ? -1 : 1); + if (adx > ady) { + major = adx; + minorLen = ady; + majorX = sx; + majorY = 0; } else { - steps = absDy; - half = (int16_t)(steps / 2); - for (i = 0; i <= steps; i++) { - py = (int16_t)(y0 + sy * i); - px = (int16_t)(x0 + (dx * i + sx * half) / steps); - plot(pic, state, px, py); + major = ady; + minorLen = adx; + majorX = 0; + majorY = sy; + } + minorX = (int16_t)(sx - majorX); + minorY = (int16_t)(sy - majorY); + byteError = agiQuirk(AGI_QUIRK_LINE_BYTE_ERROR); + err = (uint16_t)(major / 2); + for (i = 0; i <= major; i++) { + plot(d, x0, y0); + x0 = (int16_t)(x0 + majorX); + y0 = (int16_t)(y0 + majorY); + err = (uint16_t)(err + (uint16_t)minorLen); + if (byteError) { + err = (uint8_t)err; + } + if (err >= (uint16_t)major) { + err = (uint16_t)(err - (uint16_t)major); + x0 = (int16_t)(x0 + minorX); + y0 = (int16_t)(y0 + minorY); } } } -// Scanline flood fill on one plane. Replaces every cell of value -// `bgValue` 4-connected to (x, y) with `newValue`. The seed stack is -// bounded; runs that don't fit are silently dropped, which mirrors -// the original interpreter's behavior under pathological PIC inputs. -static void fillPlane(uint8_t *plane, int16_t x, int16_t y, uint8_t bgValue, uint8_t newValue) { - static int16_t stackX[FILL_STACK_MAX]; - static int16_t stackY[FILL_STACK_MAX]; - uint16_t sp; - int16_t cx; - int16_t cy; - int16_t left; - int16_t right; - int16_t scanX; - bool aboveOpen; - bool belowOpen; +// Flood fill from (x, y) over the pixels fillable() accepts, painting +// whichever buffers are enabled. +static void fill(const PicDrawT *d, int16_t x, int16_t y) { + uint16_t sp; + int16_t left; + int16_t right; + int16_t scan; + int16_t ny; + uint16_t row; + bool open; - if (x < 0 || x >= AGI_PIC_WIDTH || y < 0 || y >= AGI_PIC_HEIGHT) { + if (x < 0 || x > AGI_MAX_X || y < 0 || y > AGI_MAX_Y) { return; } - if (plane[y * AGI_PIC_WIDTH + x] != bgValue) { + // Filling with the colour being searched for would never end. + if (d->visualOn) { + if (d->color == AGI_PIC_BG_COLOR) { + return; + } + } else if (!d->priorityOn || d->priorityColor == AGI_PIC_BG_PRIORITY) { return; } - if (bgValue == newValue) { - return; - } - - sp = 0; - stackX[sp] = x; - stackY[sp] = y; - sp++; - + gFillX[0] = (uint8_t)x; + gFillY[0] = (uint8_t)y; + sp = 1u; while (sp > 0u) { sp--; - cx = stackX[sp]; - cy = stackY[sp]; - - if (plane[cy * AGI_PIC_WIDTH + cx] != bgValue) { + x = gFillX[sp]; + y = gFillY[sp]; + row = (uint16_t)y * AGI_WIDTH; + if (!fillable(d, (uint16_t)(row + (uint16_t)x))) { continue; } - - // Expand the current row to the bg run's full extent. - left = cx; - while (left > 0 && plane[cy * AGI_PIC_WIDTH + (left - 1)] == bgValue) { + left = x; + while (left > 0 && fillable(d, (uint16_t)(row + (uint16_t)(left - 1)))) { left--; } - right = cx; - while (right < (int16_t)(AGI_PIC_WIDTH - 1) && plane[cy * AGI_PIC_WIDTH + (right + 1)] == bgValue) { + right = x; + while (right < AGI_MAX_X && fillable(d, (uint16_t)(row + (uint16_t)(right + 1)))) { right++; } - for (scanX = left; scanX <= right; scanX++) { - plane[cy * AGI_PIC_WIDTH + scanX] = newValue; - } - - // Push one seed per bg run on the row above. - if (cy > 0) { - aboveOpen = false; - for (scanX = left; scanX <= right; scanX++) { - if (plane[(cy - 1) * AGI_PIC_WIDTH + scanX] == bgValue) { - if (!aboveOpen) { - if (sp < FILL_STACK_MAX) { - stackX[sp] = scanX; - stackY[sp] = (int16_t)(cy - 1); - sp++; - } - aboveOpen = true; - } - } else { - aboveOpen = false; - } + for (scan = left; scan <= right; scan++) { + if (d->visualOn) { + d->visual[row + (uint16_t)scan] = d->color; + } + if (d->priorityOn) { + d->priority[row + (uint16_t)scan] = d->priorityColor; } } - // And one seed per bg run on the row below. - if (cy < (int16_t)(AGI_PIC_HEIGHT - 1)) { - belowOpen = false; - for (scanX = left; scanX <= right; scanX++) { - if (plane[(cy + 1) * AGI_PIC_WIDTH + scanX] == bgValue) { - if (!belowOpen) { - if (sp < FILL_STACK_MAX) { - stackX[sp] = scanX; - stackY[sp] = (int16_t)(cy + 1); - sp++; - } - belowOpen = true; + for (ny = (int16_t)(y - 1); ny <= (int16_t)(y + 1); ny = (int16_t)(ny + 2)) { + if (ny < 0 || ny > AGI_MAX_Y) { + continue; + } + open = false; + for (scan = left; scan <= right; scan++) { + if (fillable(d, (uint16_t)((uint16_t)ny * AGI_WIDTH + (uint16_t)scan))) { + if (!open && sp < FILL_STACK_MAX) { + gFillX[sp] = (uint8_t)scan; + gFillY[sp] = (uint8_t)ny; + sp++; } + open = true; } else { - belowOpen = false; + open = false; } } } @@ -258,402 +257,281 @@ static void fillPlane(uint8_t *plane, int16_t x, int16_t y, uint8_t bgValue, uin } -static bool isOpcode(uint8_t byte) { - return byte >= OP_FIRST; +// A fill spreads over white in the visual buffer when that is being +// drawn, otherwise over priority 4 in the priority buffer. +static bool fillable(const PicDrawT *d, uint16_t idx) { + if (d->visualOn) { + return d->visual[idx] == AGI_PIC_BG_COLOR; + } + return d->priority[idx] == AGI_PIC_BG_PRIORITY; } -static void opAbsLine(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos) { - uint16_t p; - int16_t prevX; - int16_t prevY; - int16_t curX; - int16_t curY; - bool have; +// The absolute line action: a start point, then a line to each point. +static uint16_t opAbsLine(const PicDrawT *d, const uint8_t *data, uint16_t length, uint16_t pos) { + int16_t x; + int16_t y; - p = *pos; - have = false; - prevX = 0; - prevY = 0; - while (p + 1 < length && !isOpcode(data[p])) { - curX = (int16_t)data[p++]; - curY = (int16_t)data[p++]; - if (!have) { - plot(pic, state, curX, curY); - have = true; + if (pos + 1u >= length || data[pos] >= PIC_FIRST_ACTION || data[pos + 1u] >= PIC_FIRST_ACTION) { + return pos; + } + x = data[pos]; + y = data[pos + 1u]; + pos = (uint16_t)(pos + 2u); + plot(d, x, y); + while (pos + 1u < length && data[pos] < PIC_FIRST_ACTION && data[pos + 1u] < PIC_FIRST_ACTION) { + drawLine(d, x, y, data[pos], data[pos + 1u]); + x = data[pos]; + y = data[pos + 1u]; + pos = (uint16_t)(pos + 2u); + } + return pos; +} + + +// The corner actions: a start point, then alternately a new y and a new x +// (or x first), a line drawn to each. +static uint16_t opCorner(const PicDrawT *d, const uint8_t *data, uint16_t length, uint16_t pos, bool yFirst) { + int16_t x; + int16_t y; + bool changeY; + + if (pos + 1u >= length || data[pos] >= PIC_FIRST_ACTION || data[pos + 1u] >= PIC_FIRST_ACTION) { + return pos; + } + x = data[pos]; + y = data[pos + 1u]; + pos = (uint16_t)(pos + 2u); + changeY = yFirst; + plot(d, x, y); + while (pos < length && data[pos] < PIC_FIRST_ACTION) { + if (changeY) { + drawLine(d, x, y, x, data[pos]); + y = data[pos]; } else { - drawLine(pic, state, prevX, prevY, curX, curY); + drawLine(d, x, y, data[pos], y); + x = data[pos]; } - prevX = curX; - prevY = curY; + pos++; + changeY = !changeY; } - *pos = p; + return pos; } -static void opFill(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos) { - uint16_t p; - int16_t x; - int16_t y; +static uint16_t opPlotPen(const PicDrawT *d, const uint8_t *data, uint16_t length, uint16_t pos) { + uint8_t texture; - p = *pos; - while (p + 1 < length && !isOpcode(data[p])) { - x = (int16_t)data[p++]; - y = (int16_t)data[p++]; - if (state->visualEnabled) { - fillPlane(pic->visual, x, y, AGI_VISUAL_BG, state->visualColor); - } - if (state->priorityEnabled) { - fillPlane(pic->priority, x, y, AGI_PRIORITY_BG, state->priorityColor); - } - } - *pos = p; -} - - -// Plot-with-pen. With pen patterning off, each iteration consumes an -// X,Y pair and plots a single pixel. With patterning on, each -// iteration consumes a texture byte first (we discard it -- proper -// patterned brushes are deferred to a later pass). Size and shape -// are likewise stubbed to single-pixel; KQ3 PICs use pens sparingly -// and dropping the brush footprint just thins occasional grass / sky -// dithering. -static void opPlotPen(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos) { - uint16_t p; - int16_t x; - int16_t y; - - p = *pos; - while (p < length && !isOpcode(data[p])) { - if (state->penPattern) { - // Skip the texture-selector byte. - p++; - if (p >= length || isOpcode(data[p])) { + texture = 0u; + for (;;) { + if ((d->pen & PEN_SPLATTER) != 0u) { + if (pos >= length || data[pos] >= PIC_FIRST_ACTION) { break; } + texture = data[pos]; + pos++; } - if (p + 1 >= length) { + if (pos + 1u >= length || data[pos] >= PIC_FIRST_ACTION || data[pos + 1u] >= PIC_FIRST_ACTION) { break; } - x = (int16_t)data[p++]; - y = (int16_t)data[p++]; - plot(pic, state, x, y); + plotPen(d, data[pos], data[pos + 1u], texture); + pos = (uint16_t)(pos + 2u); } - *pos = p; + return pos; } -static void opRelLine(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos) { - uint16_t p; - int16_t curX; - int16_t curY; - int16_t newX; - int16_t newY; - uint8_t disp; - int16_t dx; - int16_t dy; +// The relative line action: a start point, then a line per byte to a +// point at most 7 pixels away on each axis. +static uint16_t opRelLine(const PicDrawT *d, const uint8_t *data, uint16_t length, uint16_t pos) { + int16_t x; + int16_t y; + int16_t nx; + int16_t ny; + uint8_t b; - p = *pos; - if (p + 1 >= length) { - *pos = p; - return; + if (pos + 1u >= length || data[pos] >= PIC_FIRST_ACTION || data[pos + 1u] >= PIC_FIRST_ACTION) { + return pos; } - curX = (int16_t)data[p++]; - curY = (int16_t)data[p++]; - plot(pic, state, curX, curY); - - while (p < length && !isOpcode(data[p])) { - disp = data[p++]; - // High nibble = X disp (bit 7 sign, bits 4-6 magnitude 0..7). - // Low nibble = Y disp (bit 3 sign, bits 0-2 magnitude 0..7). - dx = (int16_t)((disp >> 4) & 0x07u); - if (disp & 0x80u) { - dx = (int16_t)-dx; + x = data[pos]; + y = data[pos + 1u]; + pos = (uint16_t)(pos + 2u); + plot(d, x, y); + while (pos < length && data[pos] < PIC_FIRST_ACTION) { + b = data[pos++]; + nx = (int16_t)((b >> REL_X_SHIFT) & REL_MAG_MASK); + ny = (int16_t)(b & REL_MAG_MASK); + if ((b & REL_X_SIGN) != 0u) { + nx = (int16_t)-nx; } - dy = (int16_t)(disp & 0x07u); - if (disp & 0x08u) { - dy = (int16_t)-dy; + if ((b & REL_Y_SIGN) != 0u) { + ny = (int16_t)-ny; } - newX = (int16_t)(curX + dx); - newY = (int16_t)(curY + dy); - drawLine(pic, state, curX, curY, newX, newY); - curX = newX; - curY = newY; + nx = (int16_t)(x + nx); + ny = (int16_t)(y + ny); + drawLine(d, x, y, nx, ny); + x = nx; + y = ny; } - *pos = p; + return pos; } -static void plot(AgiPicT *pic, const PicStateT *state, int16_t x, int16_t y) { +static void plot(const PicDrawT *d, int16_t x, int16_t y) { uint16_t idx; - if (x < 0 || x >= (int16_t)AGI_PIC_WIDTH) { + if (x < 0 || x > AGI_MAX_X || y < 0 || y > AGI_MAX_Y) { return; } - if (y < 0 || y >= (int16_t)AGI_PIC_HEIGHT) { - return; + idx = (uint16_t)((uint16_t)y * AGI_WIDTH + (uint16_t)x); + if (d->visualOn) { + d->visual[idx] = d->color; } - idx = (uint16_t)(y * (int16_t)AGI_PIC_WIDTH + x); - if (state->visualEnabled) { - pic->visual[idx] = state->visualColor; + if (d->priorityOn) { + d->priority[idx] = d->priorityColor; } - if (state->priorityEnabled) { - pic->priority[idx] = state->priorityColor; +} + + +// Plot the current pen centred on (x, y): a rectangle or circle size+1 +// wide and 2*size+1 tall, solid or masked by a texture's bits. +static void plotPen(const PicDrawT *d, int16_t x, int16_t y, uint8_t texture) { + uint8_t size; + uint8_t rows; + uint8_t r; + uint8_t c; + uint8_t bits; + uint8_t bitPos; + int16_t left; + int16_t top; + bool circle; + bool splatter; + + size = (uint8_t)(d->pen & PEN_SIZE_MASK); + rows = (uint8_t)(size * 2u + 1u); + circle = (d->pen & PEN_RECTANGLE) == 0u; + splatter = (d->pen & PEN_SPLATTER) != 0u; + left = (int16_t)(x - (int16_t)((size + 1u) / 2u)); + top = (int16_t)(y - (int16_t)size); + bitPos = kTextureStart[(texture >> TEXTURE_CODE_SHIFT) % sizeof(kTextureStart)]; + for (r = 0u; r < rows; r++) { + bits = circle ? kCircleRows[size][r] : (uint8_t)(PEN_ROW_FULL << (PEN_SIZES - 1u - size)); + for (c = 0u; c <= size; c++) { + if ((bits & (uint8_t)(PEN_ROW_MSB >> c)) == 0u) { + continue; + } + if (splatter) { + bool on; + + on = (kTextureBits[bitPos >> BIT_BYTE_SHIFT] & (uint8_t)(PEN_ROW_MSB >> (bitPos & BIT_INDEX_MASK))) != 0u; + // The texture wraps at bit 254, not 255 (specification 7.2). + bitPos = (uint8_t)((bitPos >= TEXTURE_BITS_LAST) ? 0u : bitPos + 1u); + if (!on) { + continue; + } + } + plot(d, (int16_t)(left + (int16_t)c), (int16_t)(top + (int16_t)r)); + } } } // ----- Public API (alphabetical) ----- -bool agiPicAlloc(AgiPicT *pic) { - pic->visual = (uint8_t *)jlAlloc(AGI_PIC_PIXELS); - pic->priority = (uint8_t *)jlAlloc(AGI_PIC_PIXELS); - if (pic->visual == NULL || pic->priority == NULL) { - agiPicFree(pic); - return false; - } - return true; -} - - -void agiPicBlit(const AgiPicT *pic, jlSurfaceT *stage, int16_t destY) { - int16_t sy; - int16_t sx; - int16_t dx; - uint8_t color; - - // Fast path: chunky shadow exists -> write packed bytes direct, - // mark the whole region dirty once at the end. ~30x faster than - // calling jlDrawPixel 53,760 times (each does bounds + 1-pixel - // dirty mark). Required for state-2 / state-3 title transitions - // to render under a second on period-correct DOSBox CPU cycles. - if (stage != NULL && stage->pixels != NULL) { - const uint8_t *src = pic->visual; - uint8_t *dst; - int16_t rowOff; - - for (sy = 0; sy < (int16_t)AGI_PIC_HEIGHT; sy++) { - rowOff = (int16_t)((int16_t)(destY + sy) * (int16_t)SURFACE_BYTES_PER_ROW); - dst = &stage->pixels[rowOff]; - for (sx = 0; sx < (int16_t)AGI_PIC_WIDTH; sx++) { - color = (uint8_t)(src[sy * AGI_PIC_WIDTH + sx] & 0x0Fu); - // Pixel-doubled: each source pixel = 1 dst byte - // (both nibbles = same color). - dst[sx] = (uint8_t)((color << 4) | color); - } - } - surfaceMarkDirtyRect(stage, 0, destY, - (int16_t)SURFACE_WIDTH, (int16_t)AGI_PIC_HEIGHT); - return; - } - - // Planar fallback (s->pixels NULL: Phase-9 Amiga). Goes through - // jlDrawPixel so the port's c2p / plane-sync path stays correct. - for (sy = 0; sy < (int16_t)AGI_PIC_HEIGHT; sy++) { - for (sx = 0; sx < (int16_t)AGI_PIC_WIDTH; sx++) { - color = pic->visual[sy * AGI_PIC_WIDTH + sx]; - dx = (int16_t)(sx << 1); - jlDrawPixel(stage, dx, (int16_t)(destY + sy), color); - jlDrawPixel(stage, (int16_t)(dx + 1), (int16_t)(destY + sy), color); - } - } -} - - -void agiPicClear(AgiPicT *pic) { - if (pic->visual != NULL) { - memset(pic->visual, AGI_VISUAL_BG, AGI_PIC_PIXELS); - } - if (pic->priority != NULL) { - memset(pic->priority, AGI_PRIORITY_BG, AGI_PIC_PIXELS); - } -} - - -bool agiPicDecode(AgiPicT *pic, const uint8_t *data, uint16_t length) { - PicStateT state; - uint16_t pos; - uint8_t op; - - state.visualEnabled = 0u; - state.priorityEnabled = 0u; - state.visualColor = 0u; - state.priorityColor = 0u; - state.penPattern = 0u; +// Draw a picture over the buffers' current contents (draw.pic clears them +// first, overlay.pic does not). Returns false on a malformed stream, with +// whatever was drawn so far left in place. +bool agiPicDecode(uint8_t *visual, uint8_t *priority, const uint8_t *data, uint16_t length) { + PicDrawT d; + uint16_t pos; + uint8_t op; + d.visual = visual; + d.priority = priority; + d.visualOn = false; + d.priorityOn = false; + d.color = 0u; + d.priorityColor = 0u; + d.pen = 0u; pos = 0u; while (pos < length) { op = data[pos++]; switch (op) { - case OP_SET_PIC_COLOR: + case PIC_SET_COLOR: if (pos >= length) { return false; } - state.visualColor = data[pos++]; - state.visualEnabled = 1u; + d.color = (uint8_t)(data[pos++] & AGI_COLOR_MASK); + d.visualOn = true; break; - case OP_DISABLE_PIC: - state.visualEnabled = 0u; + case PIC_NO_COLOR: + d.visualOn = false; break; - case OP_SET_PRI_COLOR: + case PIC_SET_PRIORITY: if (pos >= length) { return false; } - state.priorityColor = data[pos++]; - state.priorityEnabled = 1u; + d.priorityColor = (uint8_t)(data[pos++] & AGI_COLOR_MASK); + d.priorityOn = true; break; - case OP_DISABLE_PRI: - state.priorityEnabled = 0u; + case PIC_NO_PRIORITY: + d.priorityOn = false; break; - case OP_Y_CORNER: - drawCorner(pic, &state, data, length, &pos, true); + case PIC_Y_CORNER: + case PIC_X_CORNER: + pos = opCorner(&d, data, length, pos, op == PIC_Y_CORNER); break; - case OP_X_CORNER: - drawCorner(pic, &state, data, length, &pos, false); + case PIC_ABS_LINE: + pos = opAbsLine(&d, data, length, pos); break; - case OP_ABS_LINE: - opAbsLine(pic, &state, data, length, &pos); + case PIC_REL_LINE: + pos = opRelLine(&d, data, length, pos); break; - case OP_REL_LINE: - opRelLine(pic, &state, data, length, &pos); + case PIC_FILL: + while (pos + 1u < length && data[pos] < PIC_FIRST_ACTION && data[pos + 1u] < PIC_FIRST_ACTION) { + fill(&d, data[pos], data[pos + 1u]); + pos = (uint16_t)(pos + 2u); + } break; - case OP_FILL: - opFill(pic, &state, data, length, &pos); - break; - case OP_SET_PEN: + case PIC_SET_PEN: if (pos >= length) { return false; } - state.penPattern = (uint8_t)((data[pos++] & PEN_PATTERN_BIT) ? 1u : 0u); + d.pen = data[pos++]; break; - case OP_PLOT_PEN: - opPlotPen(pic, &state, data, length, &pos); + case PIC_PLOT_PEN: + pos = opPlotPen(&d, data, length, pos); break; - case OP_END: + case PIC_END: return true; default: - // Unknown opcode -- stop cleanly rather than misparsing. - return false; + // Unknown action bytes are skipped with their arguments. + while (pos < length && data[pos] < PIC_FIRST_ACTION) { + pos++; + } + break; } } return true; } -// Bake a single VIEW cel into the visual + priority planes (the -// implementation of add.to.pic). Pixels outside the picture window -// or set to the cel's transparent color are skipped. priColor in -// 4..15 overwrites the priority pixels where the visual is opaque; -// 0 preserves the existing priority. RLE format matches agiViewDraw -// (per-row 0-byte EOR marker after a variable run). -void agiPicAddView(AgiPicT *pic, const AgiViewT *view, - uint8_t loop, uint8_t cel, - int16_t baseX, int16_t baseY, - uint8_t priColor, uint8_t margin) { - const AgiCelInfoT *info; - const AgiCelInfoT *resolved; - const uint8_t *rle; - uint8_t actualLoop; - int16_t width; - int16_t height; - uint8_t trans; - bool mirrored; - int16_t topY; - int16_t cy; - int16_t cx; - int16_t drawCx; - int16_t agiX; - int16_t agiY; - uint8_t rleByte; - uint8_t color; - uint8_t runLen; - uint8_t r; - - (void)margin; - if (view == NULL || pic == NULL || pic->visual == NULL) { - return; +uint8_t agiPriorityForY(int16_t y) { + if (y < AGI_PRI_BAND_TOP) { + return AGI_PRIORITY_MIN; } - if (loop >= view->loopCount) { - return; - } - if (cel >= view->loops[loop].celCount) { - return; - } - info = &view->loops[loop].cels[cel]; - actualLoop = loop; - mirrored = false; - if (info->mirrored != 0u) { - if (info->mirrorSourceLoop != loop && info->mirrorSourceLoop < view->loopCount - && cel < view->loops[info->mirrorSourceLoop].celCount) { - actualLoop = info->mirrorSourceLoop; - mirrored = true; - } - } - resolved = &view->loops[actualLoop].cels[cel]; - - width = (int16_t)resolved->width; - height = (int16_t)resolved->height; - trans = resolved->transparentColor; - rle = resolved->rleData; - topY = (int16_t)(baseY - height + 1); - - for (cy = 0; cy < height; cy++) { - cx = 0; - for (;;) { - rleByte = *rle++; - if (rleByte == 0u) { - // End-of-row marker. - break; - } - color = (uint8_t)(rleByte >> 4); - runLen = (uint8_t)(rleByte & 0x0Fu); - for (r = 0u; r < runLen; r++) { - if (cx >= width) { - break; - } - if (color != trans) { - drawCx = mirrored ? (int16_t)(width - 1 - cx) : cx; - agiX = (int16_t)(baseX + drawCx); - agiY = (int16_t)(topY + cy); - if (agiX >= 0 && agiX < (int16_t)AGI_PIC_WIDTH && agiY >= 0 && agiY < (int16_t)AGI_PIC_HEIGHT) { - int16_t idx = (int16_t)(agiY * (int16_t)AGI_PIC_WIDTH + agiX); - bool paint = true; - - // AGI v2 priority masking: when priColor is a - // real priority value (>= AGI_PRIORITY_BG), - // skip the pixel if the existing priority is - // strictly higher -- the new art is behind - // existing higher-priority art. This is how - // KQ3's title "III" ends up behind the KQ - // logo (III priColor=4 = background, KQ logo - // in PIC 45 sits at a higher band). - if (priColor >= AGI_PRIORITY_BG && priColor < 16u && pic->priority != NULL) { - if (pic->priority[idx] > priColor) { - paint = false; - } - } - if (paint) { - pic->visual[idx] = color; - if (priColor >= AGI_PRIORITY_BG && priColor < 16u && pic->priority != NULL) { - pic->priority[idx] = priColor; - } - } - } - } - cx++; - } - } + if (y > AGI_MAX_Y) { + y = AGI_MAX_Y; } + return (uint8_t)(AGI_PRI_FIRST_BAND + (uint8_t)((y - AGI_PRI_BAND_TOP) / AGI_PRI_BAND_HEIGHT)); } -void agiPicFree(AgiPicT *pic) { - if (pic->visual != NULL) { - jlFree(pic->visual); - pic->visual = NULL; - } - if (pic->priority != NULL) { - jlFree(pic->priority); - pic->priority = NULL; +// The lowest y that has a priority: where an object with that fixed +// priority sorts among the others when they are drawn. +int16_t agiYForPriority(uint8_t priority) { + if (priority <= AGI_PRIORITY_MIN) { + return 0; } + return (int16_t)(AGI_PRI_BAND_TOP + (int16_t)(priority - AGI_PRI_FIRST_BAND) * AGI_PRI_BAND_HEIGHT); } diff --git a/agiRes.c b/agiRes.c index 32da2cf..848cc44 100644 --- a/agiRes.c +++ b/agiRes.c @@ -1,267 +1,312 @@ -// AGI v2 resource loader. +// AGI v2 resources: the four directories, the VOL files, WORDS.TOK and +// OBJECT (AGI specification chapters 5 and 10). // -// Parses LOGDIR/PICDIR/VIEWDIR/SNDDIR into in-RAM index tables, keeps -// every VOL.x file open for the session, and exposes a single -// resource-by-id load primitive that hands back a freshly-allocated -// payload buffer. -// -// AGI v2 directory entry format (3 bytes, big-endian within the byte -// stream): byte0 high nibble is the volume number, the 20-bit value -// formed from (byte0 & 0x0F):byte1:byte2 is the file offset. All-FF -// means the slot is empty (no resource with that ID). -// -// VOL.x record header (5 bytes at the directory's offset): 0x12 0x34 -// signature, then the volume number, then a little-endian 16-bit -// payload length. v2 payloads are stored raw; v3 sets bit 7 of the -// volume byte to flag LZW compression (not handled here). +// 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. #include "agi.h" #include -#include -#include #include +#include "joey/core.h" #include "joey/file.h" -// AGI directory files are flat arrays of 3-byte entries. The maximum -// directory length the loader will accept is bounded by AGI_MAX_- -// RESOURCES; larger files are reported as bad rather than truncated. -#define AGI_DIR_ENTRY_BYTES 3 +#define DIR_ENTRY_BYTES 3u +#define DIR_EMPTY_BYTE 0xFFu +#define DIR_EMPTY_VOLUME 0xFFu +#define DIR_VOLUME_SHIFT 4u +#define DIR_OFFSET_HIGH_MASK 0x0Fu +#define DIR_OFFSET_HIGH_SHIFT 16u +#define VOL_HEADER_BYTES 5u +#define VOL_SIG_0 0x12u +#define VOL_SIG_1 0x34u +#define VOL_COMPRESSED 0x80u +#define VOL_VOLUME_MASK 0x7Fu +#define VOL_NAME_MAX 8u +#define DECIMAL_BASE 10u -// VOL.x resource records start with a 5-byte header. -#define AGI_VOL_HEADER_BYTES 5 -#define AGI_VOL_SIG_0 0x12 -#define AGI_VOL_SIG_1 0x34 -#define AGI_VOL_COMPRESSED 0x80 -#define AGI_VOL_VOLUME_MASK 0x7F +// 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). +// Offsets count from the first entry. The file is XORed with the same +// key as LOGIC messages. +#define OBJECT_HEADER_BYTES 3u +#define OBJECT_ENTRY_BYTES 3u +#define OBJECT_ROOM_BYTE 2u +#define OBJECT_MAX_BYTES 8192u +#define OBJECT_MAX_OBJS_BYTE 2u -// Resource file names are opened relative to DATA/ via jlDataOpen (see -// joey/file.h); the longest name AGI forms is "VOL.". 16 is ample. -#define AGI_VOL_NAME_MAX 16 +// WORDS.TOK: 26 big-endian letter offsets, then entries of a prefix +// count, characters XORed with 0x7F (bit 7 marks the last) and a +// big-endian group number. +#define WORD_CHAR_XOR 0x7Fu +#define WORD_LAST_CHAR 0x80u +#define WORD_INDEX_BYTES 2u -// Empty-slot sentinel: must be >= AGI_MAX_VOLUMES so agiResLoad -// rejects empty entries. The on-disk empty marker is 0xFFFFFF (high -// nibble 0xF = volume 15); 15 is a legitimate volume number, so we -// don't reuse it as the sentinel. -#define AGI_DIR_EMPTY_VOLUME 0xFF +#define CRYPT_KEY "Avis Durgan" +#define CRYPT_KEY_LEN 11u + +// The interpreter's version string, "Version 2.435", in AGIDATA.OVL (or +// in the AGI executable of interpreters without one). +#define VERSION_TAG "Version " +#define VERSION_FILE_MAX 65535u +#define VERSION_MINOR_DIGITS 3u +#define VERSION_MAJOR_SCALE 1000u // ----- Prototypes ----- -static bool buildVolName(char *out, size_t outSize, uint8_t volNum); -static bool loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type); -static bool loadWords(AgiGameT *game); -static bool openVolumes(AgiGameT *game); -static const char *resTypeFileName(AgiResTypeE type); -static bool wordEqCI(const char *a, const char *b); +static void buildVolName(char *out, uint8_t volNum); +static uint16_t detectVersion(void); +static void loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type); +static uint8_t *loadFile(const char *name, uint16_t maxBytes, uint16_t *outLength); +static bool loadObjects(AgiGameT *game); + + +// ----- Tables ----- + +// The directory files, by AgiResTypeE. +static const char *const kDirNames[AGI_RES_COUNT] = { "LOGDIR", "PICDIR", "VIEWDIR", "SNDDIR" }; + +// Where the version string may be, in the order to look. +static const char *const kVersionFiles[] = { "AGIDATA.OVL", "AGI" }; + +// 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 +// they are assumed for every 2.x interpreter until one is measured +// otherwise. +typedef struct { + uint16_t first; + uint16_t last; +} QuirkRangeT; + +static const QuirkRangeT kQuirkVersions[AGI_QUIRK_COUNT] = { + { 2000u, 2999u }, // AGI_QUIRK_WINDOW_BYTE_X + { 2000u, 2999u }, // AGI_QUIRK_ECHO_STEPS_BACK + { 2000u, 2999u } // AGI_QUIRK_LINE_BYTE_ERROR +}; // ----- Internal helpers (alphabetical) ----- -static bool buildVolName(char *out, size_t outSize, uint8_t volNum) { - char *p; +static void buildVolName(char *out, uint8_t volNum) { + uint8_t n; - // "VOL." + up to 2 digits + NUL = 7 chars max. - if (outSize < 8u) { - return false; + n = 0u; + out[n++] = 'V'; + out[n++] = 'O'; + out[n++] = 'L'; + out[n++] = '.'; + if (volNum >= DECIMAL_BASE) { + out[n++] = (char)('0' + volNum / DECIMAL_BASE); } - p = out; - *p++ = 'V'; - *p++ = 'O'; - *p++ = 'L'; - *p++ = '.'; - if (volNum >= 10u) { - *p++ = (char)('0' + (volNum / 10u)); - *p++ = (char)('0' + (volNum % 10u)); - } else { - *p++ = (char)('0' + volNum); - } - *p = '\0'; - return true; + out[n++] = (char)('0' + volNum % DECIMAL_BASE); + out[n] = '\0'; } -static bool loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type) { - // One directory is resident at a time; a file-scope buffer keeps this - // ~768-byte read off the shallow IIgs soft stack. - static uint8_t dirBytes[AGI_MAX_RESOURCES * AGI_DIR_ENTRY_BYTES]; - FILE *fp; - uint16_t entryCount; - uint16_t i; - uint8_t volNibble; - uint32_t offset; - size_t got; +// The version Sierra's interpreter files in the game folder name, or +// AGI_VERSION_MEASURED when there are none. +static uint16_t detectVersion(void) { + uint8_t *data; + uint16_t length; + uint16_t i; + uint16_t j; + uint16_t major; + uint16_t minor; + uint8_t f; - // fileName is a bare name relative to DATA/ (e.g. "LOGDIR"). - fp = jlDataOpen(fileName, "rb"); - if (fp == NULL) { - return false; + for (f = 0u; f < sizeof(kVersionFiles) / sizeof(kVersionFiles[0]); f++) { + data = loadFile(kVersionFiles[f], VERSION_FILE_MAX, &length); + if (data == NULL) { + continue; + } + for (i = 0u; (uint32_t)i + sizeof(VERSION_TAG) + VERSION_MINOR_DIGITS + 1u < length; i++) { + if (memcmp(&data[i], VERSION_TAG, sizeof(VERSION_TAG) - 1u) != 0) { + continue; + } + j = (uint16_t)(i + sizeof(VERSION_TAG) - 1u); + if (data[j] < '0' || data[j] > '9' || data[j + 1u] != '.') { + continue; + } + major = (uint16_t)(data[j] - '0'); + minor = 0u; + 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')); + } + jlFree(data); + return (uint16_t)(major * VERSION_MAJOR_SCALE + minor); + } + jlFree(data); } + return AGI_VERSION_MEASURED; +} - // Read the whole flat 3-byte-entry array in one call rather than one - // read per entry: on the IIgs each fread is a GS/OS round trip through - // SmartPort firmware, and a directory never exceeds AGI_MAX_RESOURCES - // entries, so the fixed buffer holds any valid file. A non-multiple-of-3 - // length means the file is malformed. - got = fread(dirBytes, 1, sizeof(dirBytes), fp); - fclose(fp); - if (got == 0u || (got % AGI_DIR_ENTRY_BYTES) != 0u) { - return false; + +// 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; + 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); + for (i = 0u; i < count; i++) { + const uint8_t *e = &bytes[i * DIR_ENTRY_BYTES]; + AgiResEntryT *d = &game->resDir[type][i]; - entryCount = (uint16_t)(got / AGI_DIR_ENTRY_BYTES); - for (i = 0; i < entryCount; i++) { - const uint8_t *e = &dirBytes[(uint32_t)i * AGI_DIR_ENTRY_BYTES]; - - volNibble = (uint8_t)(e[0] >> 4); - // 20-bit offset, cast to uint32_t before shift so a - // 16-bit int doesn't truncate the (& 0x0F) << 16 term. - offset = ((uint32_t)(e[0] & 0x0F) << 16) | ((uint32_t)e[1] << 8) | (uint32_t)e[2]; - - if (e[0] == 0xFF && e[1] == 0xFF && e[2] == 0xFF) { - game->resDir[type][i].volume = AGI_DIR_EMPTY_VOLUME; - game->resDir[type][i].offset = 0u; + if (e[0] == DIR_EMPTY_BYTE && e[1] == DIR_EMPTY_BYTE && e[2] == DIR_EMPTY_BYTE) { + d->volume = DIR_EMPTY_VOLUME; } else { - game->resDir[type][i].volume = volNibble; - game->resDir[type][i].offset = offset; + d->volume = (uint8_t)(e[0] >> DIR_VOLUME_SHIFT); + d->offsetHigh = (uint8_t)(e[0] & DIR_OFFSET_HIGH_MASK); + d->offsetLow = (uint16_t)(((uint16_t)e[1] << 8) | e[2]); } } - - game->resCount[type] = entryCount; - return true; + game->resCount[type] = count; + jlFree(bytes); } -static bool loadWords(AgiGameT *game) { +// 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. +static uint8_t *loadFile(const char *name, uint16_t maxBytes, uint16_t *outLength) { FILE *fp; uint8_t *buf; - size_t got; + long size; - game->words = NULL; - game->wordsLen = 0u; - - // The dictionary is optional: a game with no WORDS.TOK simply can't - // match said()/parse (no crash), so a missing file is not an error. - fp = jlDataOpen("WORDS.TOK", "rb"); + fp = jlDataOpen(name, "rb"); if (fp == NULL) { - return true; + return NULL; } - buf = (uint8_t *)malloc(AGI_WORDS_MAX); - if (buf == NULL) { - fclose(fp); + buf = NULL; + if (fseek(fp, 0L, SEEK_END) == 0) { + size = ftell(fp); + if (size > (long)maxBytes) { + size = (long)maxBytes; + } + if (size > 0L && fseek(fp, 0L, SEEK_SET) == 0) { + buf = (uint8_t *)jlAlloc((uint32_t)size); + } + if (buf != NULL && fread(buf, 1, (size_t)size, fp) != (size_t)size) { + jlFree(buf); + buf = NULL; + } + *outLength = (uint16_t)size; + } + fclose(fp); + return buf; +} + + +static bool loadObjects(AgiGameT *game) { + uint8_t *data; + uint16_t length; + uint16_t namesOffset; + + data = loadFile("OBJECT", OBJECT_MAX_BYTES, &length); + if (data == NULL) { return false; } - got = fread(buf, 1, AGI_WORDS_MAX, fp); - fclose(fp); - // Anything shorter than the letter-index header can't hold a word. - if (got <= AGI_WORDS_HEADER_BYTES) { - free(buf); - return true; + if (length < OBJECT_HEADER_BYTES) { + jlFree(data); + return false; + } + agiDecrypt(data, length); + namesOffset = (uint16_t)(data[0] | ((uint16_t)data[1] << 8)); + game->objectData = data; + game->itemCount = (uint16_t)(namesOffset / OBJECT_ENTRY_BYTES); + if (game->itemCount > AGI_MAX_ITEMS) { + game->itemCount = AGI_MAX_ITEMS; + } + game->maxObjects = data[OBJECT_MAX_OBJS_BYTE]; + if (game->maxObjects == 0u || game->maxObjects > AGI_MAX_OBJECTS) { + game->maxObjects = AGI_MAX_OBJECTS; } - game->words = buf; - game->wordsLen = (uint32_t)got; return true; } -static bool openVolumes(AgiGameT *game) { - char name[AGI_VOL_NAME_MAX]; - uint8_t v; - bool anyOpened; - - anyOpened = false; - for (v = 0; v < AGI_MAX_VOLUMES; v++) { - if (!buildVolName(name, sizeof(name), v)) { - return false; - } - game->volFiles[v] = jlDataOpen(name, "rb"); - if (game->volFiles[v] != NULL) { - anyOpened = true; - } - } - return anyOpened; -} - - -static const char *resTypeFileName(AgiResTypeE type) { - switch (type) { - case AGI_RES_LOGIC: return "LOGDIR"; - case AGI_RES_PIC: return "PICDIR"; - case AGI_RES_VIEW: return "VIEWDIR"; - case AGI_RES_SOUND: return "SNDDIR"; - default: return NULL; - } -} - - -// Case-insensitive ASCII equality. Both dictionary words and (lowercased) -// typed words are ASCII; fold A-Z so a stray uppercase still matches. -static bool wordEqCI(const char *a, const char *b) { - while (*a != '\0' && *b != '\0') { - char ca; - char cb; - - ca = *a; - cb = *b; - if (ca >= 'A' && ca <= 'Z') { - ca = (char)(ca + ('a' - 'A')); - } - if (cb >= 'A' && cb <= 'Z') { - cb = (char)(cb + ('a' - 'A')); - } - if (ca != cb) { - return false; - } - a++; - b++; - } - return *a == '\0' && *b == '\0'; -} - - // ----- Public API (alphabetical) ----- +// Undo Sierra's XOR with "Avis Durgan" (OBJECT, and LOGIC messages from +// the start of their text). +void agiDecrypt(uint8_t *data, uint16_t length) { + uint16_t i; + + for (i = 0u; i < length; i++) { + data[i] = (uint8_t)(data[i] ^ (uint8_t)CRYPT_KEY[i % CRYPT_KEY_LEN]); + } +} + + +const char *agiItemName(const AgiGameT *game, uint8_t item) { + const uint8_t *e; + uint16_t off; + + if (game->objectData == NULL || item >= game->itemCount) { + return ""; + } + e = &game->objectData[OBJECT_HEADER_BYTES + (uint16_t)item * OBJECT_ENTRY_BYTES]; + off = (uint16_t)(e[0] | ((uint16_t)e[1] << 8)); + return (const char *)&game->objectData[OBJECT_HEADER_BYTES + off]; +} + + +uint8_t agiItemStartRoom(const AgiGameT *game, uint8_t item) { + if (game->objectData == NULL || item >= game->itemCount) { + return 0u; + } + return game->objectData[OBJECT_HEADER_BYTES + (uint16_t)item * OBJECT_ENTRY_BYTES + OBJECT_ROOM_BYTE]; +} + + +// Whether the game's interpreter version has a quirk. +bool agiQuirk(AgiQuirkE quirk) { + uint16_t v; + + v = gAgi->game.interpreterVersion; + return quirk < AGI_QUIRK_COUNT && v >= kQuirkVersions[quirk].first && v <= kQuirkVersions[quirk].last; +} + + void agiResClose(AgiGameT *game) { uint8_t v; - uint8_t t; - for (v = 0; v < AGI_MAX_VOLUMES; v++) { + for (v = 0u; v < AGI_MAX_VOLUMES; v++) { if (game->volFiles[v] != NULL) { fclose(game->volFiles[v]); game->volFiles[v] = NULL; } } - for (t = 0; t < AGI_RES_COUNT; t++) { - game->resCount[t] = 0u; - } - if (game->words != NULL) { - free(game->words); - game->words = NULL; - game->wordsLen = 0u; - } + jlFree(game->words); + game->words = NULL; + jlFree(game->objectData); + game->objectData = NULL; } uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength) { const AgiResEntryT *entry; FILE *fp; - uint8_t header[AGI_VOL_HEADER_BYTES]; + uint8_t header[VOL_HEADER_BYTES]; uint8_t *payload; uint16_t length; - uint8_t flagsAndVolume; + long offset; - if (outLength != NULL) { - *outLength = 0u; - } - if (type >= AGI_RES_COUNT) { - return NULL; - } if (index >= game->resCount[type]) { return NULL; } - entry = &game->resDir[type][index]; if (entry->volume >= AGI_MAX_VOLUMES) { return NULL; @@ -270,40 +315,32 @@ uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint if (fp == NULL) { return NULL; } - - if (fseek(fp, (long)entry->offset, SEEK_SET) != 0) { + offset = ((long)entry->offsetHigh << DIR_OFFSET_HIGH_SHIFT) | (long)entry->offsetLow; + if (fseek(fp, offset, SEEK_SET) != 0) { return NULL; } - if (fread(header, 1, AGI_VOL_HEADER_BYTES, fp) != AGI_VOL_HEADER_BYTES) { + if (fread(header, 1, VOL_HEADER_BYTES, fp) != VOL_HEADER_BYTES) { return NULL; } - if (header[0] != AGI_VOL_SIG_0 || header[1] != AGI_VOL_SIG_1) { + if (header[0] != VOL_SIG_0 || header[1] != VOL_SIG_1) { return NULL; } - - flagsAndVolume = header[2]; - if (flagsAndVolume & AGI_VOL_COMPRESSED) { - // v3 LZW-compressed resource. Phase 2 work; not handled yet. + // v3 resources are LZW-compressed and flag it in the volume byte. + if ((header[2] & VOL_COMPRESSED) != 0u || (header[2] & VOL_VOLUME_MASK) != entry->volume) { return NULL; } - if ((flagsAndVolume & AGI_VOL_VOLUME_MASK) != entry->volume) { - return NULL; - } - - length = (uint16_t)(((uint16_t)header[4] << 8) | (uint16_t)header[3]); + length = (uint16_t)(header[3] | ((uint16_t)header[4] << 8)); if (length == 0u) { return NULL; } - - payload = (uint8_t *)malloc(length); + payload = (uint8_t *)jlAlloc(length); if (payload == NULL) { return NULL; } if (fread(payload, 1, length, fp) != length) { - free(payload); + jlFree(payload); return NULL; } - if (outLength != NULL) { *outLength = length; } @@ -312,89 +349,107 @@ uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint bool agiResOpen(AgiGameT *game) { + char name[VOL_NAME_MAX]; uint8_t t; + uint8_t v; + bool anyVolume; memset(game, 0, sizeof(*game)); - - // Resource files are opened by bare name relative to DATA/ (jlDataOpen); - // the game's data is whatever the packager staged there, so there is no - // runtime game-directory to thread through. - for (t = 0; t < AGI_RES_COUNT; t++) { - if (!loadDirectory(game, resTypeFileName((AgiResTypeE)t), (AgiResTypeE)t)) { - agiResClose(game); - return false; + for (t = 0u; t < AGI_RES_COUNT; t++) { + loadDirectory(game, kDirNames[t], (AgiResTypeE)t); + } + if (game->resCount[AGI_RES_LOGIC] == 0u) { + agiResClose(game); + return false; + } + anyVolume = false; + for (v = 0u; v < AGI_MAX_VOLUMES; v++) { + buildVolName(name, v); + game->volFiles[v] = jlDataOpen(name, "rb"); + if (game->volFiles[v] != NULL) { + anyVolume = true; } } - - if (!openVolumes(game)) { + if (!anyVolume) { agiResClose(game); return false; } - - // The dictionary is optional (loadWords only fails on OOM); a game - // without WORDS.TOK still runs, just with no parser matches. - if (!loadWords(game)) { - agiResClose(game); - return false; + // A game without WORDS.TOK just never matches said(); without OBJECT + // it has no inventory. + game->words = loadFile("WORDS.TOK", AGI_WORDS_MAX, &game->wordsLen); + if (game->words != NULL && game->wordsLen <= AGI_WORDS_HEADER_BYTES) { + jlFree(game->words); + game->words = NULL; } - + if (!loadObjects(game)) { + game->maxObjects = AGI_MAX_OBJECTS; + } + game->interpreterVersion = detectVersion(); return true; } -uint16_t agiWordId(const AgiGameT *game, const char *word) { +// Find the longest dictionary entry that starts `text` and ends at a word +// boundary (the end of text or a space). Entries can hold several words +// ("pick up"). text is lower case with single spaces. Returns the word's +// group, or AGI_WORD_NONE, and its length in *outLen. +uint16_t agiWordMatch(const AgiGameT *game, const char *text, uint8_t *outLen) { + char cur[AGI_WORD_MAX_LEN + 1u]; const uint8_t *p; const uint8_t *end; - char cur[AGI_WORD_MAX_LEN + 1u]; + uint16_t best; + uint8_t bestLen; uint8_t len; + uint8_t letter; + uint16_t start; - if (game->words == NULL || word == NULL) { - return AGI_WORD_UNKNOWN; + *outLen = 0u; + if (game->words == NULL || text[0] < 'a' || text[0] > 'z') { + return AGI_WORD_NONE; } - - // Word entries follow the fixed 52-byte letter-index header. Each entry: - // 1 byte prefixLen -- chars shared with the previous word - // n bytes suffix -- each (c & 0x7F) ^ 0x7F; the char with bit 7 - // set is the last one of the word - // 2 bytes groupId -- big-endian - // `cur` persists across entries so prefixLen chars carry forward. - p = game->words + AGI_WORDS_HEADER_BYTES; - end = game->words + game->wordsLen; - len = 0u; + letter = (uint8_t)(text[0] - 'a'); + start = (uint16_t)(((uint16_t)game->words[letter * WORD_INDEX_BYTES] << 8) | game->words[letter * WORD_INDEX_BYTES + 1u]); + if (start == 0u || start >= game->wordsLen) { + return AGI_WORD_NONE; + } + best = AGI_WORD_NONE; + bestLen = 0u; + p = game->words + start; + end = game->words + game->wordsLen; while (p < end) { - uint8_t prefixLen; + uint8_t c; uint16_t id; - prefixLen = *p++; - if (prefixLen > AGI_WORD_MAX_LEN) { - break; // malformed dictionary + len = *p++; + if (len > AGI_WORD_MAX_LEN) { + break; } - len = prefixLen; - for (;;) { - uint8_t c; - + do { if (p >= end) { - return AGI_WORD_UNKNOWN; + *outLen = bestLen; + return best; } c = *p++; if (len < AGI_WORD_MAX_LEN) { - cur[len] = (char)((c & 0x7Fu) ^ 0x7Fu); + cur[len] = (char)((c & (uint8_t)~WORD_LAST_CHAR) ^ WORD_CHAR_XOR); len++; } - if ((c & 0x80u) != 0u) { - break; - } - } - cur[len] = '\0'; - + } while ((c & WORD_LAST_CHAR) == 0u); if (p + 1 >= end) { - return AGI_WORD_UNKNOWN; + break; } - id = (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]); + id = (uint16_t)(((uint16_t)p[0] << 8) | p[1]); p += 2; - if (wordEqCI(cur, word)) { - return id; + // Entries are sorted, so once the first letter changes nothing + // further can match. + if (cur[0] != text[0]) { + break; + } + if (len > bestLen && strncmp(cur, text, len) == 0 && (text[len] == '\0' || text[len] == ' ')) { + best = id; + bestLen = len; } } - return AGI_WORD_UNKNOWN; + *outLen = bestLen; + return best; } diff --git a/agiSave.c b/agiSave.c new file mode 100644 index 0000000..3239acf --- /dev/null +++ b/agiSave.c @@ -0,0 +1,402 @@ +// save.game and restore.game. +// +// A save holds the interpreter state that is not rebuilt from the game +// files: variables, flags, strings, the inventory, the screen objects, +// the screen layout and the logics in memory. The picture is not saved; +// as in Sierra's interpreter, the commands that built the current room's +// picture (draw.pic, overlay.pic, add.to.pic, and the loads) are kept in +// a script, which is saved and played back on restore. new.room empties +// the script; f7 stops commands being added. +// +// 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. + +#include "agi.h" + +#include +#include +#include + +#include "joey/core.h" +#include "joey/draw.h" +#include "joey/file.h" + + +#define SAVE_MAGIC_0 'J' +#define SAVE_MAGIC_1 'A' +#define SAVE_MAGIC_2 'G' +#define SAVE_VERSION 1u +#define SAVE_SLOTS 9u +#define SAVE_DESC_MAX 30u +#define SAVE_NAME_MAX 12u +// The slot window is as wide as the screen allows with its frame. +#define PICK_WIDTH (AGI_TEXT_COLS - 4u) + + +// One saved object: AgiObjectT without its pointers and blit state. +typedef struct { + uint8_t viewId; + uint8_t loop; + uint8_t cel; + uint8_t priority; + int16_t x; + int16_t y; + uint16_t flags; + uint8_t direction; + uint8_t stepSize; + uint8_t stepTime; + uint8_t stepCount; + uint8_t cycleTime; + 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; +} SavedObjectT; + +typedef struct { + char magic[3]; + uint8_t version; + char description[SAVE_DESC_MAX + 1u]; + uint8_t vars[AGI_NUM_VARS]; + uint8_t flags[AGI_NUM_FLAGS]; + char strings[AGI_MAX_STRINGS][AGI_STRING_LEN + 1u]; + uint8_t itemRoom[AGI_MAX_ITEMS]; + AgiKeyMapT keyMaps[AGI_MAX_KEYMAPS]; + uint8_t keyMapCount; + SavedObjectT objects[AGI_MAX_OBJECTS]; + bool logicLoaded[AGI_MAX_RESOURCES]; + uint16_t logicScanStart[AGI_MAX_RESOURCES]; + bool menuEnabled[AGI_MAX_MENU_ITEMS]; + uint8_t horizon; + bool blockActive; + uint8_t blockX1; + uint8_t blockY1; + uint8_t blockX2; + uint8_t blockY2; + bool playerControl; + uint8_t picRow; + uint8_t inputRow; + uint8_t statusRow; + bool statusOn; + bool inputOn; + uint8_t textFg; + uint8_t textBg; + char cursorChar; + uint8_t scriptCount; + AgiScriptEntryT script[AGI_SCRIPT_MAX]; +} SaveFileT; + + +// ----- Prototypes ----- + +static bool fileBuffer(void); +static uint8_t pickSlot(const char *title, bool mustExist); +static bool readDescription(uint8_t slot, char *out); +static void slotName(uint8_t slot, char *out); + + +// ----- Module state ----- + +static SaveFileT *gFile; + + +// ----- Internal helpers (alphabetical) ----- + +// The save file's image, allocated on first use (it is too big for the +// IIgs's bank 0). +static bool fileBuffer(void) { + if (gFile == NULL) { + gFile = (SaveFileT *)jlAlloc(sizeof(SaveFileT)); + } + return gFile != NULL; +} + + +static uint8_t pickSlot(const char *title, bool mustExist) { + char text[(SAVE_SLOTS + 3u) * (AGI_TEXT_COLS + 1u)]; + char desc[SAVE_DESC_MAX + 1u]; + uint8_t slot; + uint16_t key; + size_t n; + + // 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)) { + strcpy(desc, "-"); + } + n += (size_t)snprintf(text + n, sizeof(text) - n, "%u. %s\n", (unsigned)slot, desc); + } + if (n < sizeof(text)) { + (void)snprintf(text + n, sizeof(text) - n, "\nPress 1-9, or ESC to cancel."); + } + textPrint(text, -1, -1, PICK_WIDTH); + for (;;) { + key = inputWaitKey(); + if (key == AGI_KEY_ESCAPE) { + slot = 0u; + break; + } + if (key >= (uint16_t)'1' && key <= (uint16_t)('0' + SAVE_SLOTS)) { + slot = (uint8_t)(key - '0'); + if (!mustExist || readDescription(slot, desc)) { + break; + } + } + } + textCloseWindow(); + return slot; +} + + +// A slot's description, read from the start of its file. +static bool readDescription(uint8_t slot, char *out) { + char name[SAVE_NAME_MAX]; + FILE *fp; + char head[offsetof(SaveFileT, description) + SAVE_DESC_MAX + 1u]; + + slotName(slot, name); + fp = jlSaveOpen(name, "rb"); + 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) { + fclose(fp); + return false; + } + fclose(fp); + memcpy(out, &head[offsetof(SaveFileT, description)], SAVE_DESC_MAX + 1u); + out[SAVE_DESC_MAX] = '\0'; + return true; +} + + +static void slotName(uint8_t slot, char *out) { + (void)snprintf(out, SAVE_NAME_MAX, "AGISAVE%u", (unsigned)slot); +} + + +// ----- Public API (alphabetical) ----- + +bool restoreGame(void) { + char name[SAVE_NAME_MAX]; + uint8_t slot; + uint8_t i; + uint16_t r; + + slot = pickSlot("Restore which game?", true); + if (slot == 0u) { + return false; + } + if (!fileBuffer()) { + return false; + } + slotName(slot, name); + if (jlSaveRead(name, gFile, sizeof(SaveFileT)) != sizeof(SaveFileT) || gFile->version != SAVE_VERSION) { + textMessageBox("Error in restoring game."); + return false; + } + soundStop(); + gfxEraseAll(); + agiResetState(); + memcpy(gAgi->vars, gFile->vars, sizeof(gAgi->vars)); + memcpy(gAgi->flags, gFile->flags, sizeof(gAgi->flags)); + memcpy(gAgi->strings, gFile->strings, sizeof(gAgi->strings)); + memcpy(gAgi->itemRoom, gFile->itemRoom, sizeof(gAgi->itemRoom)); + memcpy(gAgi->keyMaps, gFile->keyMaps, sizeof(gAgi->keyMaps)); + gAgi->keyMapCount = gFile->keyMapCount; + for (r = 0u; r < AGI_MAX_RESOURCES; r++) { + gAgi->logicLoaded[r] = gFile->logicLoaded[r]; + gAgi->logicScanStart[r] = gFile->logicScanStart[r]; + } + for (i = 0u; i < gAgi->menuItemCount && i < AGI_MAX_MENU_ITEMS; i++) { + gAgi->menuItems[i].enabled = gFile->menuEnabled[i]; + } + gAgi->horizon = gFile->horizon; + gAgi->blockActive = gFile->blockActive; + gAgi->blockX1 = gFile->blockX1; + gAgi->blockY1 = gFile->blockY1; + gAgi->blockX2 = gFile->blockX2; + gAgi->blockY2 = gFile->blockY2; + gAgi->playerControl = gFile->playerControl; + gAgi->picRow = gFile->picRow; + gAgi->inputRow = gFile->inputRow; + gAgi->statusRow = gFile->statusRow; + gAgi->statusOn = gFile->statusOn; + gAgi->inputOn = gFile->inputOn; + gAgi->textFg = gFile->textFg; + gAgi->textBg = gFile->textBg; + gAgi->cursorChar = gFile->cursorChar; + // Rebuild the picture with no objects drawn, then the objects. + gAgi->scriptCount = gFile->scriptCount; + memcpy(gAgi->script, gFile->script, sizeof(gAgi->script)); + for (i = 0u; i < gAgi->scriptCount; i++) { + vmReplay(&gAgi->script[i]); + } + for (i = 0u; i < AGI_MAX_OBJECTS; i++) { + const SavedObjectT *s = &gFile->objects[i]; + AgiObjectT *o = &gAgi->objects[i]; + + memset(o, 0, sizeof(*o)); + if ((s->flags & OBJ_ANIMATED) != 0u) { + (void)objSetView(i, s->viewId); + } + o->loop = s->loop; + o->cel = s->cel; + objSetLoop(i, s->loop); + objSetCel(i, s->cel); + o->priority = s->priority; + o->x = s->x; + o->y = s->y; + o->prevX = s->x; + o->prevY = s->y; + o->flags = s->flags; + o->direction = s->direction; + o->stepSize = s->stepSize; + o->stepTime = s->stepTime; + o->stepCount = s->stepCount; + o->cycleTime = s->cycleTime; + 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; + } + gfxDrawStatic(); + jlSurfaceClear(jlStageGet(), AGI_COLOR_BLACK); + gfxShowPic(); + textDrawStatus(); + textDrawInputLine(); + gAgi->flags[FLAG_RESTORED] = 1u; + gAgi->restored = true; + return true; +} + + +void saveGame(void) { + char name[SAVE_NAME_MAX]; + char desc[SAVE_DESC_MAX + 1u]; + uint8_t slot; + uint8_t i; + uint16_t r; + + slot = pickSlot("Save in which slot?", false); + if (slot == 0u) { + return; + } + desc[0] = '\0'; + if (!inputGetString(desc, SAVE_DESC_MAX, AGI_NO_POSITION, AGI_NO_POSITION, "Description: ")) { + return; + } + if (!fileBuffer()) { + return; + } + memset(gFile, 0, sizeof(SaveFileT)); + gFile->magic[0] = SAVE_MAGIC_0; + gFile->magic[1] = SAVE_MAGIC_1; + gFile->magic[2] = SAVE_MAGIC_2; + gFile->version = SAVE_VERSION; + memcpy(gFile->description, desc, sizeof(desc)); + memcpy(gFile->vars, gAgi->vars, sizeof(gFile->vars)); + memcpy(gFile->flags, gAgi->flags, sizeof(gFile->flags)); + memcpy(gFile->strings, gAgi->strings, sizeof(gFile->strings)); + memcpy(gFile->itemRoom, gAgi->itemRoom, sizeof(gFile->itemRoom)); + memcpy(gFile->keyMaps, gAgi->keyMaps, sizeof(gFile->keyMaps)); + gFile->keyMapCount = gAgi->keyMapCount; + for (r = 0u; r < AGI_MAX_RESOURCES; r++) { + gFile->logicLoaded[r] = gAgi->logicLoaded[r]; + gFile->logicScanStart[r] = gAgi->logicScanStart[r]; + } + for (i = 0u; i < gAgi->menuItemCount && i < AGI_MAX_MENU_ITEMS; i++) { + gFile->menuEnabled[i] = gAgi->menuItems[i].enabled; + } + gFile->horizon = gAgi->horizon; + gFile->blockActive = gAgi->blockActive; + gFile->blockX1 = gAgi->blockX1; + gFile->blockY1 = gAgi->blockY1; + gFile->blockX2 = gAgi->blockX2; + gFile->blockY2 = gAgi->blockY2; + gFile->playerControl = gAgi->playerControl; + gFile->picRow = gAgi->picRow; + gFile->inputRow = gAgi->inputRow; + gFile->statusRow = gAgi->statusRow; + gFile->statusOn = gAgi->statusOn; + gFile->inputOn = gAgi->inputOn; + gFile->textFg = gAgi->textFg; + gFile->textBg = gAgi->textBg; + gFile->cursorChar = gAgi->cursorChar; + for (i = 0u; i < AGI_MAX_OBJECTS; i++) { + const AgiObjectT *o = &gAgi->objects[i]; + SavedObjectT *s = &gFile->objects[i]; + + s->viewId = o->viewId; + s->loop = o->loop; + s->cel = o->cel; + s->priority = o->priority; + s->x = o->x; + s->y = o->y; + s->flags = o->flags; + s->direction = o->direction; + s->stepSize = o->stepSize; + s->stepTime = o->stepTime; + s->stepCount = o->stepCount; + s->cycleTime = o->cycleTime; + 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; + } + gFile->scriptCount = gAgi->scriptCount; + memcpy(gFile->script, gAgi->script, sizeof(gFile->script)); + slotName(slot, name); + if (!jlSaveWrite(name, gFile, sizeof(SaveFileT))) { + textMessageBox("The game could not be saved."); + } +} + + +// Remember a picture-building command for restore (unless f7 blocks it). +void saveScriptAdd(uint8_t op, uint8_t arg) { + uint8_t args[AGI_SCRIPT_ARGS]; + + memset(args, 0, sizeof(args)); + args[0] = arg; + saveScriptAddArgs(op, args); +} + + +void saveScriptAddArgs(uint8_t op, const uint8_t *args) { + AgiScriptEntryT *e; + + if (gAgi->flags[FLAG_NO_SCRIPT] != 0u || gAgi->scriptCount >= AGI_SCRIPT_MAX) { + return; + } + e = &gAgi->script[gAgi->scriptCount]; + e->op = op; + memcpy(e->args, args, AGI_SCRIPT_ARGS); + gAgi->scriptCount++; +} + + +// new.room: the new room's picture starts from nothing. +void saveScriptClear(void) { + gAgi->scriptCount = 0u; +} diff --git a/agiSound.c b/agiSound.c new file mode 100644 index 0000000..2184737 --- /dev/null +++ b/agiSound.c @@ -0,0 +1,262 @@ +// SOUND resources on JoeyLib's three tone voices and noise voice (AGI +// specification 9.2 and 9.5). +// +// A sound is four voices -- three tones and the noise -- each a list of +// 5-byte notes: a 16-bit duration in 1/60 s ticks, then the PCjr sound +// chip bytes in reverse order: the high six bits of the tone's 10-bit +// divisor, a command byte with its low four bits (or, for the noise +// voice, the noise type and rate), and the attenuation (0 loudest, 15 +// off). The tone is 111860 / divisor Hz. A voice ends at an FF FF +// duration. The sound's flag is set when the first voice ends, as in +// Sierra's PC interpreter, whose speaker plays only that voice (measured: +// longer second, third and noise voices do not delay it); the others play +// on here until they end or another sound starts. With sound turned off +// (f9 clear) the flag is set at once. +// +// soundPump runs the notes from the millisecond clock every frame, and +// re-asserts the voices (the IIgs's voices are one-shots that must be +// re-triggered about once a frame). + +#include "agi.h" + +#include + +#include "joey/audio.h" +#include "joey/core.h" + + +#define SOUND_VOICES 4u +#define SOUND_TONES 3u +#define SOUND_NOISE 3u +#define SOUND_HEADER_BYTES 8u +#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 +#define DIVISOR_HIGH_SHIFT 4u +#define ATTEN_MASK 0x0Fu +#define ATTEN_OFF 15u +#define NOISE_RATE_MASK 0x03u +#define NOISE_RATE_TONE3 3u +// JoeyLib's noise pitch runs 0 (brightest) to 31: the chip's three fixed +// rates an octave apart, and the third tone's divisor scaled down. +#define NOISE_PITCH_FIRST 4u +#define NOISE_PITCH_MAX 31u +#define NOISE_DIVISOR_SHIFT 5u + + +typedef struct { + uint16_t pos; + uint16_t ticksLeft; + bool done; +} VoiceT; + + +// ----- Prototypes ----- + +static void playNote(uint8_t voice, const uint8_t *note); +static void raiseFlag(void); +static void release(void); +static void silence(void); + + +// ----- Module state ----- + +static uint8_t *gData; +static uint16_t gLength; +static VoiceT gVoices[SOUND_VOICES]; +static uint16_t gToneHz[SOUND_TONES]; +static uint8_t gToneAtten[SOUND_TONES]; +static uint16_t gToneDivisor[SOUND_TONES]; +static uint8_t gNoisePitch; +static uint8_t gNoiseAtten; +static uint32_t gTickStartMs; +static uint32_t gTicksDone; +// The flag sound() named, still to be set while the first voice plays. +static uint8_t gFlag; +static bool gFlagPending; + + +// ----- Internal helpers (alphabetical) ----- + +static void playNote(uint8_t voice, const uint8_t *note) { + uint16_t divisor; + uint8_t atten; + + atten = (uint8_t)(note[4] & ATTEN_MASK); + if (voice == SOUND_NOISE) { + uint8_t rate; + + rate = (uint8_t)(note[3] & NOISE_RATE_MASK); + if (rate == NOISE_RATE_TONE3) { + gNoisePitch = (uint8_t)(gToneDivisor[SOUND_TONES - 1u] >> NOISE_DIVISOR_SHIFT); + } else { + gNoisePitch = (uint8_t)(NOISE_PITCH_FIRST << rate); + } + if (gNoisePitch > NOISE_PITCH_MAX) { + gNoisePitch = NOISE_PITCH_MAX; + } + gNoiseAtten = atten; + jlAudioNoise(gNoisePitch, gNoiseAtten); + return; + } + divisor = (uint16_t)(((uint16_t)(note[2] & DIVISOR_HIGH_MASK) << DIVISOR_HIGH_SHIFT) | (note[3] & DIVISOR_LOW_MASK)); + gToneDivisor[voice] = divisor; + gToneHz[voice] = (divisor == 0u) ? 0u : (uint16_t)(PSG_CLOCK_HZ / divisor); + gToneAtten[voice] = atten; + jlAudioVoice(voice, gToneHz[voice], atten); +} + + +static void raiseFlag(void) { + if (gFlagPending) { + gFlagPending = false; + gAgi->flags[gFlag] = 1u; + } +} + + +// The sound's data goes and every voice falls silent. +static void release(void) { + silence(); + jlFree(gData); + gData = NULL; +} + + +static void silence(void) { + uint8_t v; + + for (v = 0u; v < SOUND_TONES; v++) { + gToneHz[v] = 0u; + gToneAtten[v] = ATTEN_OFF; + jlAudioVoice(v, 0u, ATTEN_OFF); + } + gNoiseAtten = ATTEN_OFF; + jlAudioNoise(0u, ATTEN_OFF); +} + + +// ----- Public API (alphabetical) ----- + +void soundInit(void) { + (void)jlAudioInit(); + silence(); +} + + +// Advance the notes to the clock; called every frame. +void soundPump(void) { + uint32_t now; + uint32_t due; + uint8_t v; + bool allDone; + + if (gData == NULL) { + return; + } + now = jlMillisElapsed(); + due = (uint32_t)((now - gTickStartMs) * TICKS_PER_SECOND / MS_PER_SECOND); + while (gTicksDone < due && gData != NULL) { + gTicksDone++; + allDone = true; + for (v = 0u; v < SOUND_VOICES; v++) { + VoiceT *vc = &gVoices[v]; + + while (!vc->done && vc->ticksLeft == 0u) { + const uint8_t *note; + uint16_t dur; + + if ((uint32_t)vc->pos + NOTE_BYTES > gLength) { + vc->done = true; + break; + } + note = &gData[vc->pos]; + dur = (uint16_t)(note[0] | ((uint16_t)note[1] << 8)); + if (dur == NOTE_END) { + vc->done = true; + break; + } + playNote(v, note); + vc->pos = (uint16_t)(vc->pos + NOTE_BYTES); + vc->ticksLeft = dur; + } + if (!vc->done) { + vc->ticksLeft--; + allDone = false; + } else if (v < SOUND_TONES) { + jlAudioVoice(v, 0u, ATTEN_OFF); + gToneHz[v] = 0u; + } else { + gNoiseAtten = ATTEN_OFF; + jlAudioNoise(0u, ATTEN_OFF); + } + } + if (gVoices[0].done) { + raiseFlag(); + } + if (allDone) { + release(); + } + } +#if defined(JOEYLIB_PLATFORM_IIGS) + // The IIgs voices are one-shots: hold the notes by re-triggering. + if (gData != NULL) { + for (v = 0u; v < SOUND_TONES; v++) { + if (gToneHz[v] != 0u && gToneAtten[v] < ATTEN_OFF) { + jlAudioVoice(v, gToneHz[v], gToneAtten[v]); + } + } + if (gNoiseAtten < ATTEN_OFF) { + jlAudioNoise(gNoisePitch, gNoiseAtten); + } + } +#endif +} + + +void soundShutdown(void) { + release(); + jlAudioShutdown(); +} + + +// sound(n, f): start a sound; f is set when it ends (or is stopped). +void soundStart(uint8_t soundId, uint8_t flag) { + uint8_t v; + + soundStop(); + gFlag = flag; + gAgi->flags[flag] = 0u; + if (gAgi->flags[FLAG_SOUND] == 0u) { + gAgi->flags[flag] = 1u; + return; + } + gData = agiResLoad(&gAgi->game, AGI_RES_SOUND, soundId, &gLength); + if (gData == NULL || gLength < SOUND_HEADER_BYTES) { + jlFree(gData); + gData = NULL; + gAgi->flags[flag] = 1u; + return; + } + for (v = 0u; v < SOUND_VOICES; v++) { + gVoices[v].pos = (uint16_t)(gData[v * 2u] | ((uint16_t)gData[v * 2u + 1u] << 8)); + gVoices[v].ticksLeft = 0u; + gVoices[v].done = gVoices[v].pos == 0u || gVoices[v].pos >= gLength; + } + gFlagPending = true; + gTickStartMs = jlMillisElapsed(); + gTicksDone = 0u; +} + + +// stop.sound: silence, and the playing sound's flag is set. +void soundStop(void) { + raiseFlag(); + if (gData != NULL) { + release(); + } +} diff --git a/agiText.c b/agiText.c index 744e355..386ac3a 100644 --- a/agiText.c +++ b/agiText.c @@ -1,433 +1,1029 @@ -// AGI v2 text plane: status line, display() rows, print() modal. +// Text: display(), message windows, the status and input lines, menus, +// the inventory screen and show.obj (AGI specification 4.2's text, menu +// and inventory commands). // -// Renders directly to the stage with an embedded 8x8 ASCII bitmap -// font; bypasses jlDrawText so glyph foreground/background can vary -// per-call without rebuilding a font surface for every color combo. -// -// Layout matches AGI's 40x25 text grid: -// Row 0 : status line (when statusLineOn) -// Rows 1..21 : picture (handled by agiPicBlit, not here) -// Rows 22..24 : message overlay area -// -// The print() modal centers a window over the picture region with -// a 1-character border and waits for user ack (HALT_PRINT_PENDING). +// Text sits on the 40x25 grid of 8x8 cells, drawn straight onto the +// stage. In graphics mode Sierra's interpreter draws only white-on-black +// style text: a set.text.attribute background other than black shows as +// black on white. A message window is the wrapped text (30 columns at +// most) on white, inside a red frame, centred over the picture unless +// print.at places it; the screen under it is saved and put back when it +// closes. Geometry measured from Sierra's interpreter. #include "agi.h" -#include "joey/draw.h" -#include "joey/surface.h" -#include "surfaceInternal.h" - #include +#include #include +#include "joey/core.h" +#include "joey/draw.h" +#include "joey/tile.h" +#include "surfaceInternal.h" -#define GLYPH_W 8u -#define GLYPH_H 8u -#define FIRST_CHAR 0x20u // space -#define LAST_CHAR 0x7Eu // tilde -#define GLYPH_COUNT (LAST_CHAR - FIRST_CHAR + 1u) -#define STAGE_W SURFACE_WIDTH -#define STAGE_H SURFACE_HEIGHT +#define WINDOW_MAX_COLS 30u +#define WINDOW_MAX_LINES 20u +#define WRAP_NO_SPACE 0xFFu +// A window's box, in AGI pixels across and screen rows down: it starts 5 +// pixels left of and 5 rows above the text and is 10 pixels wider and 10 +// rows taller; a red frame runs one pixel (one row) in from the edge. +#define WINDOW_BOX_LEFT 5 +#define WINDOW_BOX_EXTRA_W 10 +#define WINDOW_BOX_TOP 5 +#define WINDOW_BOX_EXTRA_H 10 +#define WINDOW_FRAME_IN 1 +#define AGI_PX_PER_CELL (AGI_CELL_PX / AGI_SCREEN_PX_PER_AGI) +#define SCREEN_ROWS ((int16_t)AGI_TEXT_ROWS * AGI_CELL_PX) +#define SCREEN_CELLS ((int16_t)(AGI_TEXT_ROWS * AGI_TEXT_COLS)) +// jlSurfaceCopyRect copies whole groups of this many screen pixels. +#define COPY_GROUP_PX 16 +// Menu boxes, in screen pixels: the frame line two pixels in at the +// sides and one row in at the top and bottom. +#define MENU_LINE_X 2 +#define MENU_LINE_Y 1 +#define MENU_LINE_W 2 +#define MENU_LINE_H 1 +#define WINDOW_TEXT_FG AGI_COLOR_BLACK +#define WINDOW_BG AGI_COLOR_WHITE +#define WINDOW_FRAME AGI_COLOR_RED +#define STATUS_SCORE_COL 1u +#define STATUS_SOUND_COL 30u +#define FORMAT_MAX 8u +#define NUMBER_MAX_DIGITS 3u +#define DECIMAL_BASE 10u +#define EXPAND_MAX 512u +#define EXPAND_DEPTH_MAX 4u +// The inventory lists two items a row: the first at column 1, the second +// ending at column 38 (measured). +#define INVENTORY_COLS 2u +#define INVENTORY_LEFT_COL 1u +#define INVENTORY_RIGHT_END 39u +#define INVENTORY_FIRST_ROW 2u +// The hint lines' columns, as Sierra's interpreter places them. +#define INVENTORY_HINT_COL 4u +#define INVENTORY_PICK_COL 2u +#define MENU_ROW 0u +#define MENU_FIRST_COL 1u +#define MENU_ITEM_ROW 2u +#define TEXT_ATTR_BG_SHIFT 4u +#define MENU_MARGIN 8 +#define MENU_FRAME AGI_COLOR_BLACK +#define DOT_MASK_EVEN_ROW 0xAAu +#define DOT_MASK_ODD_ROW 0x55u +#define SHOW_OBJ_MARGIN 4 + + +// What windowSpan does to its pixels. +typedef enum { + SPAN_FILL = 0, + SPAN_SAVE = 1, + SPAN_RESTORE = 2 +} SpanOpE; // ----- Prototypes ----- -static void drawChar(jlSurfaceT *stage, int16_t x, int16_t y, char ch, uint8_t fg, uint8_t bg); -static void drawCharTransparent(jlSurfaceT *stage, int16_t x, int16_t y, char ch, uint8_t fg); -static void drawString(jlSurfaceT *stage, int16_t x, int16_t y, const char *s, uint8_t fg, uint8_t bg); -static void drawWindow(jlSurfaceT *stage, int16_t x, int16_t y, uint8_t cols, uint8_t rows, uint8_t bg, uint8_t border); -static void fillRow(jlSurfaceT *stage, uint8_t row, uint8_t bg); +static void attrColors(uint8_t *fg, uint8_t *bg); +static uint8_t centerCol(const char *text); +static uint8_t clearColor(uint8_t color); +static void copySpan(jlSurfaceT *dst, const jlSurfaceT *src, int16_t col, int16_t row, int16_t run); +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 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 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); +static void putNumber(char *dst, uint16_t dstCap, uint16_t *pos, uint8_t value, uint8_t width); +static void spanOp(int16_t col, int16_t row, int16_t run, SpanOpE op, uint8_t color); +static void windowBox(SpanOpE op, uint8_t color); +static void windowSpan(int16_t x, int16_t row, int16_t len, SpanOpE op, uint8_t color); +static uint8_t wrapText(const char *text, uint8_t maxCols, char lines[][AGI_TEXT_COLS + 1u], uint8_t maxLines, uint8_t *outWidth); -// 8x8 bitmap font for printable ASCII (0x20 = space .. 0x7E = tilde). -// Each glyph is 8 bytes; bit 7 is the leftmost pixel of each row. -// Lower-case letters map to upper-case via the ASCII normalizer in -// drawChar so the table only needs the upper half. -// -// Hand-pixeled to be readable rather than authentic. Each row is -// commented with the visual pattern (X = on, . = off). -static const uint8_t kFont[GLYPH_COUNT][GLYPH_H] = { - /* 0x20 ' ' */ {0,0,0,0,0,0,0,0}, - /* 0x21 '!' */ {0x18,0x18,0x18,0x18,0x18,0x00,0x18,0x00}, - /* 0x22 '"' */ {0x6C,0x6C,0x48,0x00,0x00,0x00,0x00,0x00}, - /* 0x23 '#' */ {0x36,0x36,0x7F,0x36,0x7F,0x36,0x36,0x00}, - /* 0x24 '$' */ {0x18,0x3E,0x60,0x3C,0x06,0x7C,0x18,0x00}, - /* 0x25 '%' */ {0x66,0x66,0x0C,0x18,0x30,0x66,0x66,0x00}, - /* 0x26 '&' */ {0x38,0x6C,0x38,0x76,0xDC,0xCC,0x76,0x00}, - /* 0x27 '\''*/ {0x18,0x18,0x10,0x00,0x00,0x00,0x00,0x00}, - /* 0x28 '(' */ {0x0C,0x18,0x30,0x30,0x30,0x18,0x0C,0x00}, - /* 0x29 ')' */ {0x30,0x18,0x0C,0x0C,0x0C,0x18,0x30,0x00}, - /* 0x2A '*' */ {0x00,0x66,0x3C,0xFF,0x3C,0x66,0x00,0x00}, - /* 0x2B '+' */ {0x00,0x18,0x18,0x7E,0x18,0x18,0x00,0x00}, - /* 0x2C ',' */ {0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x30}, - /* 0x2D '-' */ {0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00}, - /* 0x2E '.' */ {0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x00}, - /* 0x2F '/' */ {0x06,0x0C,0x18,0x30,0x60,0xC0,0x80,0x00}, - /* 0x30 '0' */ {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00}, - /* 0x31 '1' */ {0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00}, - /* 0x32 '2' */ {0x3C,0x66,0x06,0x1C,0x30,0x66,0x7E,0x00}, - /* 0x33 '3' */ {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00}, - /* 0x34 '4' */ {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00}, - /* 0x35 '5' */ {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00}, - /* 0x36 '6' */ {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00}, - /* 0x37 '7' */ {0x7E,0x66,0x0C,0x18,0x18,0x18,0x18,0x00}, - /* 0x38 '8' */ {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00}, - /* 0x39 '9' */ {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00}, - /* 0x3A ':' */ {0x00,0x18,0x18,0x00,0x00,0x18,0x18,0x00}, - /* 0x3B ';' */ {0x00,0x18,0x18,0x00,0x00,0x18,0x18,0x30}, - /* 0x3C '<' */ {0x0C,0x18,0x30,0x60,0x30,0x18,0x0C,0x00}, - /* 0x3D '=' */ {0x00,0x00,0x7E,0x00,0x7E,0x00,0x00,0x00}, - /* 0x3E '>' */ {0x30,0x18,0x0C,0x06,0x0C,0x18,0x30,0x00}, - /* 0x3F '?' */ {0x3C,0x66,0x06,0x0C,0x18,0x00,0x18,0x00}, - /* 0x40 '@' */ {0x3C,0x66,0x6E,0x6E,0x60,0x62,0x3C,0x00}, - /* 0x41 'A' */ {0x18,0x3C,0x66,0x66,0x7E,0x66,0x66,0x00}, - /* 0x42 'B' */ {0x7C,0x66,0x66,0x7C,0x66,0x66,0x7C,0x00}, - /* 0x43 'C' */ {0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00}, - /* 0x44 'D' */ {0x78,0x6C,0x66,0x66,0x66,0x6C,0x78,0x00}, - /* 0x45 'E' */ {0x7E,0x60,0x60,0x78,0x60,0x60,0x7E,0x00}, - /* 0x46 'F' */ {0x7E,0x60,0x60,0x78,0x60,0x60,0x60,0x00}, - /* 0x47 'G' */ {0x3C,0x66,0x60,0x6E,0x66,0x66,0x3C,0x00}, - /* 0x48 'H' */ {0x66,0x66,0x66,0x7E,0x66,0x66,0x66,0x00}, - /* 0x49 'I' */ {0x3C,0x18,0x18,0x18,0x18,0x18,0x3C,0x00}, - /* 0x4A 'J' */ {0x1E,0x0C,0x0C,0x0C,0x0C,0x6C,0x38,0x00}, - /* 0x4B 'K' */ {0x66,0x6C,0x78,0x70,0x78,0x6C,0x66,0x00}, - /* 0x4C 'L' */ {0x60,0x60,0x60,0x60,0x60,0x60,0x7E,0x00}, - /* 0x4D 'M' */ {0x63,0x77,0x7F,0x6B,0x63,0x63,0x63,0x00}, - /* 0x4E 'N' */ {0x66,0x76,0x7E,0x7E,0x6E,0x66,0x66,0x00}, - /* 0x4F 'O' */ {0x3C,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, - /* 0x50 'P' */ {0x7C,0x66,0x66,0x7C,0x60,0x60,0x60,0x00}, - /* 0x51 'Q' */ {0x3C,0x66,0x66,0x66,0x6A,0x6C,0x36,0x00}, - /* 0x52 'R' */ {0x7C,0x66,0x66,0x7C,0x6C,0x66,0x66,0x00}, - /* 0x53 'S' */ {0x3C,0x66,0x60,0x3C,0x06,0x66,0x3C,0x00}, - /* 0x54 'T' */ {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00}, - /* 0x55 'U' */ {0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, - /* 0x56 'V' */ {0x66,0x66,0x66,0x66,0x66,0x3C,0x18,0x00}, - /* 0x57 'W' */ {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00}, - /* 0x58 'X' */ {0x66,0x66,0x3C,0x18,0x3C,0x66,0x66,0x00}, - /* 0x59 'Y' */ {0x66,0x66,0x66,0x3C,0x18,0x18,0x18,0x00}, - /* 0x5A 'Z' */ {0x7E,0x06,0x0C,0x18,0x30,0x60,0x7E,0x00}, - /* 0x5B '[' */ {0x3C,0x30,0x30,0x30,0x30,0x30,0x3C,0x00}, - /* 0x5C '\\'*/ {0xC0,0x60,0x30,0x18,0x0C,0x06,0x02,0x00}, - /* 0x5D ']' */ {0x3C,0x0C,0x0C,0x0C,0x0C,0x0C,0x3C,0x00}, - /* 0x5E '^' */ {0x18,0x3C,0x66,0x00,0x00,0x00,0x00,0x00}, - /* 0x5F '_' */ {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF}, - /* 0x60 '`' */ {0x30,0x18,0x0C,0x00,0x00,0x00,0x00,0x00}, - /* 0x61 'a' */ {0x00,0x00,0x3C,0x06,0x3E,0x66,0x3E,0x00}, - /* 0x62 'b' */ {0x60,0x60,0x7C,0x66,0x66,0x66,0x7C,0x00}, - /* 0x63 'c' */ {0x00,0x00,0x3C,0x66,0x60,0x66,0x3C,0x00}, - /* 0x64 'd' */ {0x06,0x06,0x3E,0x66,0x66,0x66,0x3E,0x00}, - /* 0x65 'e' */ {0x00,0x00,0x3C,0x66,0x7E,0x60,0x3C,0x00}, - /* 0x66 'f' */ {0x1C,0x36,0x30,0x78,0x30,0x30,0x30,0x00}, - /* 0x67 'g' */ {0x00,0x00,0x3E,0x66,0x66,0x3E,0x06,0x3C}, - /* 0x68 'h' */ {0x60,0x60,0x7C,0x66,0x66,0x66,0x66,0x00}, - /* 0x69 'i' */ {0x18,0x00,0x38,0x18,0x18,0x18,0x3C,0x00}, - /* 0x6A 'j' */ {0x06,0x00,0x06,0x06,0x06,0x06,0x66,0x3C}, - /* 0x6B 'k' */ {0x60,0x60,0x66,0x6C,0x78,0x6C,0x66,0x00}, - /* 0x6C 'l' */ {0x38,0x18,0x18,0x18,0x18,0x18,0x3C,0x00}, - /* 0x6D 'm' */ {0x00,0x00,0x66,0x7F,0x7F,0x6B,0x63,0x00}, - /* 0x6E 'n' */ {0x00,0x00,0x7C,0x66,0x66,0x66,0x66,0x00}, - /* 0x6F 'o' */ {0x00,0x00,0x3C,0x66,0x66,0x66,0x3C,0x00}, - /* 0x70 'p' */ {0x00,0x00,0x7C,0x66,0x66,0x7C,0x60,0x60}, - /* 0x71 'q' */ {0x00,0x00,0x3E,0x66,0x66,0x3E,0x06,0x06}, - /* 0x72 'r' */ {0x00,0x00,0x7C,0x66,0x60,0x60,0x60,0x00}, - /* 0x73 's' */ {0x00,0x00,0x3E,0x60,0x3C,0x06,0x7C,0x00}, - /* 0x74 't' */ {0x18,0x18,0x7E,0x18,0x18,0x18,0x0E,0x00}, - /* 0x75 'u' */ {0x00,0x00,0x66,0x66,0x66,0x66,0x3E,0x00}, - /* 0x76 'v' */ {0x00,0x00,0x66,0x66,0x66,0x3C,0x18,0x00}, - /* 0x77 'w' */ {0x00,0x00,0x63,0x6B,0x7F,0x7F,0x36,0x00}, - /* 0x78 'x' */ {0x00,0x00,0x66,0x3C,0x18,0x3C,0x66,0x00}, - /* 0x79 'y' */ {0x00,0x00,0x66,0x66,0x66,0x3E,0x06,0x3C}, - /* 0x7A 'z' */ {0x00,0x00,0x7E,0x0C,0x18,0x30,0x7E,0x00}, - /* 0x7B '{' */ {0x0E,0x18,0x18,0x70,0x18,0x18,0x0E,0x00}, - /* 0x7C '|' */ {0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x00}, - /* 0x7D '}' */ {0x70,0x18,0x18,0x0E,0x18,0x18,0x70,0x00}, - /* 0x7E '~' */ {0x76,0xDC,0x00,0x00,0x00,0x00,0x00,0x00} -}; +// ----- Strings ----- + +static const char kCarrying[] = "You are carrying:"; +static const char kNothing[] = "nothing"; + + +// ----- Module state ----- + +// The open window: its save-under and its box (AGI pixels, screen rows). +static jlSurfaceT *gWinSave; +static int16_t gWinX; +static int16_t gWinY; +static int16_t gWinW; +static int16_t gWinH; +// 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. +static int16_t gEchoPos; +// What the status line shows, to redraw it only when that changes. +static uint8_t gShownScore; +static bool gShownSound; // ----- Internal helpers (alphabetical) ----- -static void drawChar(jlSurfaceT *stage, int16_t x, int16_t y, char ch, uint8_t fg, uint8_t bg) { - const uint8_t *glyph; - uint8_t row; - uint8_t col; - uint8_t bits; - uint8_t c; - uint8_t *stagePixels; +// The text attribute's colours. On the text screen they are used as they +// are; on the picture a background makes the text black on white. +static void attrColors(uint8_t *fg, uint8_t *bg) { + if (gAgi->textMode) { + *fg = gAgi->textFg; + *bg = gAgi->textBg; + return; + } + if (gAgi->textBg != AGI_COLOR_BLACK) { + *fg = AGI_COLOR_BLACK; + *bg = AGI_COLOR_WHITE; + } else { + *fg = gAgi->textFg; + *bg = AGI_COLOR_BLACK; + } +} + +// The column that centres a line of text on the screen. +static uint8_t centerCol(const char *text) { + size_t len; + + len = strlen(text); + return (uint8_t)((len >= AGI_TEXT_COLS) ? 0u : (AGI_TEXT_COLS - len) / 2u); +} + + +// clear.lines and clear.text.rect: on the picture any colour but black +// clears to white; on the text screen the colour is a PC text attribute +// whose high nibble is the background. +static uint8_t clearColor(uint8_t color) { + if (gAgi->textMode) { + return (uint8_t)((color >> TEXT_ATTR_BG_SHIFT) & AGI_COLOR_MASK); + } + return (color == AGI_COLOR_BLACK) ? AGI_COLOR_BLACK : AGI_COLOR_WHITE; +} + + +// run AGI pixels of one screen row from src to dst, and no more (a +// window's own text and frame can sit right beside a span it puts back). +// On a chunky surface an AGI pixel is one byte; a planar one copies the +// whole groups inside the run with jlSurfaceCopyRect, which would widen +// the run to its groups, and the pixels at its ends one by one. +static void copySpan(jlSurfaceT *dst, const jlSurfaceT *src, int16_t col, int16_t row, int16_t run) { + int16_t x; + int16_t xEnd; + int16_t inner; + int16_t innerEnd; + uint16_t at; + + x = (int16_t)(col * AGI_SCREEN_PX_PER_AGI); + xEnd = (int16_t)(x + run * AGI_SCREEN_PX_PER_AGI); + if (dst->pixels != NULL && src->pixels != NULL) { + at = (uint16_t)((uint16_t)row * SURFACE_BYTES_PER_ROW + (uint16_t)col); + memcpy(&dst->pixels[at], &src->pixels[at], (size_t)run); + surfaceMarkDirtyRect(dst, x, row, (int16_t)(xEnd - x), 1); + return; + } + inner = (int16_t)((x + COPY_GROUP_PX - 1) & ~(COPY_GROUP_PX - 1)); + innerEnd = (int16_t)(xEnd & ~(COPY_GROUP_PX - 1)); + if (inner >= innerEnd) { + inner = xEnd; + innerEnd = xEnd; + } else { + jlSurfaceCopyRect(dst, src, inner, row, (uint16_t)(innerEnd - inner), 1u); + } + for (; x < inner; x++) { + jlDrawPixel(dst, x, row, jlSamplePixel(src, x, row)); + } + for (x = innerEnd; x < xEnd; x++) { + jlDrawPixel(dst, x, row, jlSamplePixel(src, x, row)); + } +} + + +// A menu's white box with its frame line just inside the edge. +static void drawBox(int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t frame) { + jlSurfaceT *stage; + + stage = jlStageGet(); + jlFillRect(stage, x, y, w, h, WINDOW_BG); + jlFillRect(stage, (int16_t)(x + MENU_LINE_X), (int16_t)(y + MENU_LINE_Y), (uint16_t)(w - 2u * MENU_LINE_X), MENU_LINE_H, frame); + jlFillRect(stage, (int16_t)(x + MENU_LINE_X), (int16_t)(y + (int16_t)h - MENU_LINE_Y - MENU_LINE_H), (uint16_t)(w - 2u * MENU_LINE_X), MENU_LINE_H, frame); + jlFillRect(stage, (int16_t)(x + MENU_LINE_X), (int16_t)(y + MENU_LINE_Y), MENU_LINE_W, (uint16_t)(h - 2u * MENU_LINE_Y), frame); + jlFillRect(stage, (int16_t)(x + (int16_t)w - MENU_LINE_X - MENU_LINE_W), (int16_t)(y + MENU_LINE_Y), MENU_LINE_W, (uint16_t)(h - 2u * MENU_LINE_Y), frame); +} + + +// The menu bar across the top row, menu `current` highlighted. Returns +// the number of menus. +static uint8_t drawMenuBar(uint8_t current) { + uint8_t m; + uint8_t col; + + jlFillRect(jlStageGet(), 0, (int16_t)(MENU_ROW * AGI_CELL_PX), (uint16_t)(AGI_TEXT_COLS * AGI_CELL_PX), AGI_CELL_PX, AGI_COLOR_WHITE); + col = MENU_FIRST_COL; + for (m = 0u; m < gAgi->menuCount; m++) { + gAgi->menus[m].col = col; + if (m == current) { + drawText(MENU_ROW, col, gAgi->menus[m].name, AGI_COLOR_WHITE, AGI_COLOR_BLACK); + } else { + drawText(MENU_ROW, col, gAgi->menus[m].name, AGI_COLOR_BLACK, AGI_COLOR_WHITE); + } + col = (uint8_t)(col + strlen(gAgi->menus[m].name) + 1u); + } + return gAgi->menuCount; +} + + +// A menu's items in a black-framed box under its name, item `current` +// highlighted and disabled items dotted. Sierra's interpreter moves the +// box left when it would run off the screen. +static void drawMenuItems(uint8_t menu, uint8_t current) { + const AgiMenuT *m; + uint8_t i; + uint8_t width; + 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); + } + 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); + for (i = 0u; i < m->itemCount; i++) { + const AgiMenuItemT *it = &gAgi->menuItems[m->firstItem + i]; + const char *s; + uint8_t c; + bool hi; + + hi = (i == current); + c = col; + for (s = it->text; *s != '\0' && c < AGI_TEXT_COLS; s++, c++) { + putGlyph((uint8_t)(MENU_ITEM_ROW + i), c, *s, hi ? AGI_COLOR_WHITE : AGI_COLOR_BLACK, hi ? AGI_COLOR_BLACK : AGI_COLOR_WHITE, !it->enabled); + } + } +} + + +static void drawText(uint8_t row, uint8_t col, const char *text, uint8_t fg, uint8_t bg) { + while (*text != '\0' && col < AGI_TEXT_COLS) { + putCell(row, col, *text, fg, bg); + col++; + text++; + } +} + + +// One character of the command line's echo at gEchoPos, which moves on; +// a position off the screen draws nothing. +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); + } + gEchoPos++; +} + + +// Expand a message's format codes into dst at *pos. +static void expandInto(const char *src, char *dst, uint16_t dstCap, uint16_t *pos, uint8_t depth) { + char code; + uint8_t arg; + uint8_t width; + bool haveArg; + + while (*src != '\0' && *pos + 1u < dstCap) { + if (*src == '\\') { + // A backslash takes the next character literally. + src++; + if (*src != '\0') { + dst[(*pos)++] = *src++; + } + continue; + } + if (*src != '%') { + dst[(*pos)++] = *src++; + continue; + } + src++; + code = *src; + if (code == '\0') { + break; + } + src++; + arg = 0u; + haveArg = false; + while (*src >= '0' && *src <= '9') { + arg = (uint8_t)(arg * DECIMAL_BASE + (uint8_t)(*src - '0')); + haveArg = true; + src++; + } + // A '%' with no number after its letter shows nothing. + if (!haveArg) { + continue; + } + switch (code) { + case 'v': + width = 0u; + if (*src == '|') { + src++; + while (*src >= '0' && *src <= '9') { + width = (uint8_t)(width * DECIMAL_BASE + (uint8_t)(*src - '0')); + src++; + } + } + putNumber(dst, dstCap, pos, gAgi->vars[arg], width); + break; + case 's': + if (arg < AGI_MAX_STRINGS) { + expandInto(gAgi->strings[arg], dst, dstCap, pos, EXPAND_DEPTH_MAX); + } + break; + case 'w': + if (arg >= 1u && arg <= gAgi->wordCount) { + expandInto(gAgi->wordText[arg - 1u], dst, dstCap, pos, EXPAND_DEPTH_MAX); + } + break; + case '0': + expandInto(agiItemName(&gAgi->game, arg), dst, dstCap, pos, EXPAND_DEPTH_MAX); + break; + case 'm': + if (depth < EXPAND_DEPTH_MAX) { + expandInto(vmMessage(gAgi->currentLogic, arg), dst, dstCap, pos, (uint8_t)(depth + 1u)); + } + break; + case 'g': + if (depth < EXPAND_DEPTH_MAX) { + expandInto(vmMessage(0u, arg), dst, dstCap, pos, (uint8_t)(depth + 1u)); + } + break; + default: + break; + } + } + dst[*pos] = '\0'; +} + + +// 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). +static void openWindow(int16_t x, int16_t y, int16_t w, int16_t h) { + int16_t r; + + if (gAgi->windowOpen) { + textCloseWindow(); + } + if (gWinSave == NULL) { + gWinSave = jlSurfaceCreate(); + } + gWinX = x; + gWinY = y; + gWinW = w; + gWinH = h; + if (gWinSave != NULL) { + windowBox(SPAN_SAVE, 0u); + } + gAgi->windowOpen = true; + windowBox(SPAN_FILL, WINDOW_BG); + windowSpan((int16_t)(x + WINDOW_FRAME_IN), (int16_t)(y + WINDOW_FRAME_IN), (int16_t)(w - 2 * WINDOW_FRAME_IN), SPAN_FILL, WINDOW_FRAME); + windowSpan((int16_t)(x + WINDOW_FRAME_IN), (int16_t)(y + h - 1 - WINDOW_FRAME_IN), (int16_t)(w - 2 * WINDOW_FRAME_IN), SPAN_FILL, WINDOW_FRAME); + // The sides stop a row short of the top and bottom lines (measured). + for (r = (int16_t)(y + WINDOW_FRAME_IN + 1); r < y + h - WINDOW_FRAME_IN - 1; r++) { + windowSpan((int16_t)(x + WINDOW_FRAME_IN), r, 1, SPAN_FILL, WINDOW_FRAME); + windowSpan((int16_t)(x + w - 1 - WINDOW_FRAME_IN), r, 1, SPAN_FILL, WINDOW_FRAME); + } +} + + +static void putCell(uint8_t row, uint8_t col, char ch, uint8_t fg, uint8_t bg) { + putGlyph(row, col, ch, fg, bg, false); +} + + +// One character cell; dotted keeps only every other pixel of the glyph, +// as disabled menu items show. +static void putGlyph(uint8_t row, uint8_t col, char ch, uint8_t fg, uint8_t bg, bool dotted) { + uint8_t c; + uint8_t bits[AGI_FONT_ROWS]; + uint8_t r; + + if (row >= AGI_TEXT_ROWS || col >= AGI_TEXT_COLS) { + return; + } c = (uint8_t)ch; - if (c < FIRST_CHAR || c > LAST_CHAR) { - c = FIRST_CHAR; // unknown -> space + if (c < AGI_FONT_FIRST || c >= AGI_FONT_FIRST + AGI_FONT_GLYPHS) { + c = AGI_FONT_FIRST; } - glyph = kFont[c - FIRST_CHAR]; - - // Chunky fast path: each glyph row is 8 bits == 4 stage bytes - // (each byte = 2 pixels in 4bpp packed). Build each byte by - // checking 2 bits at a time. Bypasses jlDrawPixel's per-pixel - // overhead which dominates text rendering on slow targets. - stagePixels = (stage != NULL) ? stage->pixels : NULL; - if (stagePixels != NULL && x >= 0 && y >= 0 && - x + (int16_t)GLYPH_W <= (int16_t)SURFACE_WIDTH && - y + (int16_t)GLYPH_H <= (int16_t)SURFACE_HEIGHT && - (x & 1) == 0) { - uint16_t rowOff; - uint16_t byteX; - uint8_t bytePair; - uint8_t left; - uint8_t right; - - byteX = (uint16_t)(x >> 1); - for (row = 0u; row < GLYPH_H; row++) { - bits = glyph[row]; - rowOff = (uint16_t)((y + row) * SURFACE_BYTES_PER_ROW + byteX); - for (col = 0u; col < GLYPH_W; col += 2u) { - left = (uint8_t)((bits & (uint8_t)(0x80u >> col)) ? fg : bg); - right = (uint8_t)((bits & (uint8_t)(0x80u >> (col + 1u))) ? fg : bg); - bytePair = (uint8_t)((left << 4) | right); - stagePixels[rowOff + (col >> 1)] = bytePair; - } + for (r = 0u; r < AGI_FONT_ROWS; r++) { + bits[r] = kAgiFont[c - AGI_FONT_FIRST][r]; + if (dotted) { + bits[r] = (uint8_t)(bits[r] & (((r & 1u) != 0u) ? DOT_MASK_ODD_ROW : DOT_MASK_EVEN_ROW)); } - surfaceMarkDirtyRect(stage, x, y, (int16_t)GLYPH_W, (int16_t)GLYPH_H); + } + jlTilePasteGlyph(jlStageGet(), col, row, bits, fg, bg); +} + + +// A variable's value in decimal; a width pads it with leading zeros. +static void putNumber(char *dst, uint16_t dstCap, uint16_t *pos, uint8_t value, uint8_t width) { + char digits[NUMBER_MAX_DIGITS]; + uint8_t n; + + n = 0u; + do { + digits[n++] = (char)('0' + value % DECIMAL_BASE); + value = (uint8_t)(value / DECIMAL_BASE); + } while (value != 0u); + while (width > n && *pos + 1u < dstCap) { + dst[(*pos)++] = '0'; + width--; + } + while (n > 0u && *pos + 1u < dstCap) { + n--; + dst[(*pos)++] = digits[n]; + } +} + + +// One run of a window span on one screen row: AGI pixels col.. on row. +static void spanOp(int16_t col, int16_t row, int16_t run, SpanOpE op, uint8_t color) { + switch (op) { + case SPAN_FILL: + jlFillRect(jlStageGet(), (int16_t)(col * AGI_SCREEN_PX_PER_AGI), row, (uint16_t)(run * AGI_SCREEN_PX_PER_AGI), 1u, color); + break; + case SPAN_SAVE: + copySpan(gWinSave, jlStageGet(), col, row, run); + break; + case SPAN_RESTORE: + copySpan(jlStageGet(), gWinSave, col, row, run); + break; + default: + break; + } +} + + +// The open window's whole box, row by row. +static void windowBox(SpanOpE op, uint8_t color) { + int16_t r; + + for (r = 0; r < gWinH; r++) { + windowSpan(gWinX, (int16_t)(gWinY + r), gWinW, op, color); + } +} + + +// len AGI pixels of a window from (x, row). Sierra's interpreter +// (AGI_QUIRK_WINDOW_BYTE_X) keeps the x in a byte and walks the screen as +// one line of 160-pixel rows, so a box that starts left of the screen +// starts 256 pixels on (a row down, 96 pixels in) and a span runs on into +// the next row; elsewhere spans are just clipped to the screen. +static void windowSpan(int16_t x, int16_t row, int16_t len, SpanOpE op, uint8_t color) { + int32_t addr; + int32_t end; + int16_t run; + int16_t col; + + if (len <= 0) { return; } - - // Planar / out-of-bounds slow fallback. - for (row = 0u; row < GLYPH_H; row++) { - bits = glyph[row]; - for (col = 0u; col < GLYPH_W; col++) { - uint8_t pixel; - - pixel = ((bits >> (uint8_t)(7u - col)) & 0x01u) ? fg : bg; - jlDrawPixel(stage, (int16_t)(x + (int16_t)col), (int16_t)(y + (int16_t)row), pixel); + if (!agiQuirk(AGI_QUIRK_WINDOW_BYTE_X)) { + if (x < 0) { + len = (int16_t)(len + x); + x = 0; } - } -} - - -// Draw a char with transparent background: only foreground bits are -// written, the surface pixels underneath show through. Chunky fast -// path reads each stage byte, modifies set nibbles, writes back. -static void drawCharTransparent(jlSurfaceT *stage, int16_t x, int16_t y, char ch, uint8_t fg) { - const uint8_t *glyph; - uint8_t row; - uint8_t col; - uint8_t bits; - uint8_t c; - uint8_t *stagePixels; - - c = (uint8_t)ch; - if (c < FIRST_CHAR || c > LAST_CHAR) { - return; // unknown -> nothing to draw (transparent) - } - glyph = kFont[c - FIRST_CHAR]; - stagePixels = (stage != NULL) ? stage->pixels : NULL; - if (stagePixels != NULL && x >= 0 && y >= 0 && - x + (int16_t)GLYPH_W <= (int16_t)SURFACE_WIDTH && - y + (int16_t)GLYPH_H <= (int16_t)SURFACE_HEIGHT && - (x & 1) == 0) { - uint16_t rowOff; - uint16_t byteX; - uint8_t existing; - uint8_t leftMask; - uint8_t rightMask; - uint8_t nibbleFg; - - byteX = (uint16_t)(x >> 1); - nibbleFg = (uint8_t)(fg & 0x0Fu); - for (row = 0u; row < GLYPH_H; row++) { - bits = glyph[row]; - if (bits == 0u) { continue; } // entire row transparent - rowOff = (uint16_t)((y + row) * SURFACE_BYTES_PER_ROW + byteX); - for (col = 0u; col < GLYPH_W; col += 2u) { - leftMask = (uint8_t)(bits & (uint8_t)(0x80u >> col)); - rightMask = (uint8_t)(bits & (uint8_t)(0x80u >> (col + 1u))); - if (leftMask == 0u && rightMask == 0u) { continue; } - existing = stagePixels[rowOff + (col >> 1)]; - if (leftMask) { existing = (uint8_t)((existing & 0x0Fu) | (nibbleFg << 4)); } - if (rightMask) { existing = (uint8_t)((existing & 0xF0u) | nibbleFg); } - stagePixels[rowOff + (col >> 1)] = existing; - } + if (x + len > AGI_WIDTH) { + len = (int16_t)(AGI_WIDTH - x); + } + if (len > 0 && row >= 0 && row < SCREEN_ROWS) { + spanOp(x, row, len, op, color); } - surfaceMarkDirtyRect(stage, x, y, (int16_t)GLYPH_W, (int16_t)GLYPH_H); return; } + addr = (int32_t)row * AGI_WIDTH + (uint8_t)x; + end = addr + len; + while (addr < end) { + col = (int16_t)(addr % AGI_WIDTH); + run = (int16_t)((end - addr < AGI_WIDTH - col) ? end - addr : AGI_WIDTH - col); + if (addr >= 0 && addr / AGI_WIDTH < SCREEN_ROWS) { + spanOp(col, (int16_t)(addr / AGI_WIDTH), run, op, color); + } + addr += run; + } +} - // Slow fallback (planar or out-of-bounds). - for (row = 0u; row < GLYPH_H; row++) { - bits = glyph[row]; - for (col = 0u; col < GLYPH_W; col++) { - if (bits & (uint8_t)(0x80u >> col)) { - jlDrawPixel(stage, (int16_t)(x + (int16_t)col), (int16_t)(y + (int16_t)row), fg); + +// Word-wrap text as Sierra's interpreter does: when a line reaches +// maxCols characters it breaks at its last space (so a wrapped line holds +// at most maxCols - 1), or, with no space, right there; a newline breaks +// too. Returns the line count and the widest line in *outWidth. +static uint8_t wrapText(const char *text, uint8_t maxCols, char lines[][AGI_TEXT_COLS + 1u], uint8_t maxLines, uint8_t *outWidth) { + uint8_t count; + uint8_t len; + uint8_t lastSpace; + uint8_t carry; + char c; + + count = 0u; + len = 0u; + lastSpace = WRAP_NO_SPACE; + c = '\0'; + *outWidth = 0u; + while (*text != '\0' && count < maxLines) { + c = *text++; + if (c == '\n') { + lines[count][len] = '\0'; + if (len > *outWidth) { + *outWidth = len; } + count++; + len = 0u; + lastSpace = WRAP_NO_SPACE; + continue; } - } -} - - -static void drawString(jlSurfaceT *stage, int16_t x, int16_t y, const char *s, uint8_t fg, uint8_t bg) { - int16_t cur; - - if (s == NULL) { - return; - } - cur = x; - while (*s != '\0') { - if (cur >= (int16_t)STAGE_W) { - return; + lines[count][len++] = c; + if (c == ' ') { + lastSpace = (uint8_t)(len - 1u); } - drawChar(stage, cur, y, *s, fg, bg); - cur = (int16_t)(cur + (int16_t)GLYPH_W); - s++; + if (len < maxCols) { + continue; + } + carry = 0u; + if (lastSpace != WRAP_NO_SPACE) { + carry = (uint8_t)(len - lastSpace - 1u); + len = lastSpace; + } + lines[count][len] = '\0'; + if (len > *outWidth) { + *outWidth = len; + } + count++; + if (count >= maxLines) { + break; + } + if (carry > 0u) { + memcpy(lines[count], &lines[count - 1u][lastSpace + 1u], carry); + } + len = carry; + lastSpace = WRAP_NO_SPACE; } -} - - -static void drawWindow(jlSurfaceT *stage, int16_t x, int16_t y, uint8_t cols, uint8_t rows, uint8_t bg, uint8_t border) { - int16_t w; - int16_t h; - - w = (int16_t)((int16_t)cols * (int16_t)GLYPH_W); - h = (int16_t)((int16_t)rows * (int16_t)GLYPH_H); - jlFillRect(stage, x, y, (uint16_t)(w + 4), (uint16_t)(h + 4), border); - jlFillRect(stage, (int16_t)(x + 2), (int16_t)(y + 2), (uint16_t)w, (uint16_t)h, bg); -} - - -static void fillRow(jlSurfaceT *stage, uint8_t row, uint8_t bg) { - jlFillRect(stage, 0, (int16_t)((int16_t)row * (int16_t)GLYPH_H), (uint16_t)STAGE_W, (uint16_t)GLYPH_H, bg); + // A text ending in a newline still has its empty last line. + if (count < maxLines && (len > 0u || count == 0u || c == '\n')) { + lines[count][len] = '\0'; + if (len > *outWidth) { + *outWidth = len; + } + count++; + } + return count; } // ----- Public API (alphabetical) ----- -const uint16_t *agiTextAsciiMap(void) { - // Not used in this impl (we render direct), but expose so callers - // who want to use jlDrawText against agiTextFontSurface have the - // standard 16x6 layout map. Built lazily on first call. - static uint16_t map[256]; - static bool built; - uint16_t i; - - if (built) { - return map; +void textClearLines(uint8_t top, uint8_t bottom, uint8_t color) { + if (top >= AGI_TEXT_ROWS) { + return; } - for (i = 0u; i < 256u; i++) { - if (i < FIRST_CHAR || i > LAST_CHAR) { - map[i] = (uint16_t)0xFFFFu; - } else { - uint8_t off; - - off = (uint8_t)(i - FIRST_CHAR); - map[i] = (uint16_t)(((uint16_t)(off / 16u) << 8) | (uint16_t)(off % 16u)); - } + if (bottom >= AGI_TEXT_ROWS) { + bottom = (uint8_t)(AGI_TEXT_ROWS - 1u); } - built = true; - return map; + if (bottom < top) { + return; + } + color = clearColor(color); + jlFillRect(jlStageGet(), 0, (int16_t)((int16_t)top * AGI_CELL_PX), (uint16_t)(AGI_TEXT_COLS * AGI_CELL_PX), + (uint16_t)((uint16_t)(bottom - top + 1u) * AGI_CELL_PX), color); } -const jlSurfaceT *agiTextFontSurface(void) { - // No surface built in this impl (drawChar walks raw bits direct - // to the stage). Reserved for callers that prefer jlDrawText. - return NULL; +void textClearRect(uint8_t top, uint8_t left, uint8_t bottom, uint8_t right, uint8_t color) { + if (top >= AGI_TEXT_ROWS || left >= AGI_TEXT_COLS || bottom < top || right < left) { + return; + } + if (bottom >= AGI_TEXT_ROWS) { + bottom = (uint8_t)(AGI_TEXT_ROWS - 1u); + } + if (right >= AGI_TEXT_COLS) { + right = (uint8_t)(AGI_TEXT_COLS - 1u); + } + color = clearColor(color); + jlFillRect(jlStageGet(), (int16_t)((int16_t)left * AGI_CELL_PX), (int16_t)((int16_t)top * AGI_CELL_PX), + (uint16_t)((uint16_t)(right - left + 1u) * AGI_CELL_PX), (uint16_t)((uint16_t)(bottom - top + 1u) * AGI_CELL_PX), color); } -void agiTextRender(const AgiVmT *vm, jlSurfaceT *stage) { - uint8_t r; - - if (vm->statusLineOn) { - fillRow(stage, vm->statusLineRow, 15u); // white background - if (vm->textRows[vm->statusLineRow].text[0] != '\0') { - drawString(stage, 0, (int16_t)((int16_t)vm->statusLineRow * (int16_t)GLYPH_H), - vm->textRows[vm->statusLineRow].text, 0u, 15u); - } +void textCloseWindow(void) { + if (!gAgi->windowOpen) { + return; } - // display() rows: walk every row that has content. Glyphs draw - // transparently (no bg fill) so any PIC art behind shows through - // where glyph pixels are 0. Filling the whole row's bg would - // obscure picture elements that overlap the text band (e.g. - // KQ3's "King's Quest III" title art). - for (r = 0u; r < AGI_TEXT_ROWS; r++) { - const char *p; - int16_t x; - int16_t y; - uint8_t fg; - - if (r == vm->statusLineRow && vm->statusLineOn) { - continue; // status line handled above - } - if (vm->textRows[r].text[0] == '\0') { - continue; - } - y = (int16_t)((int16_t)r * (int16_t)GLYPH_H); - x = 0; - fg = vm->textRows[r].fg; - p = vm->textRows[r].text; - while (*p != '\0' && x < (int16_t)STAGE_W) { - if (*p != ' ') { - drawCharTransparent(stage, x, y, *p, fg); - } - x = (int16_t)(x + (int16_t)GLYPH_W); - p++; - } - } - - if (vm->printModal.active) { - const char *p; - int16_t lineW; - int16_t maxW; - int16_t lineCount; - int16_t winX; - int16_t winY; - int16_t lineH; - int16_t textY; - int16_t curX; - - // Measure: width = longest line in chars; height = lines. - lineW = 0; - maxW = 1; - lineCount = 1; - p = vm->printModal.message; - while (*p != '\0') { - if (*p == '\n') { - if (lineW > maxW) { - maxW = lineW; - } - lineW = 0; - lineCount++; - } else { - lineW++; - } - p++; - } - if (lineW > maxW) { - maxW = lineW; - } - if (maxW > (int16_t)AGI_TEXT_COLS) { - maxW = (int16_t)AGI_TEXT_COLS; - } - lineH = (int16_t)GLYPH_H; - winX = (int16_t)((STAGE_W - (maxW * (int16_t)GLYPH_W + 4)) / 2); - winY = (int16_t)((STAGE_H - (lineCount * lineH + 4)) / 2); - if (winX < 0) { winX = 0; } - if (winY < 0) { winY = 0; } - drawWindow(stage, winX, winY, (uint8_t)maxW, (uint8_t)lineCount, 15u, 4u); - - textY = (int16_t)(winY + 2); - curX = (int16_t)(winX + 2); - p = vm->printModal.message; - while (*p != '\0') { - if (*p == '\n') { - textY = (int16_t)(textY + lineH); - curX = (int16_t)(winX + 2); - } else { - drawChar(stage, curX, textY, *p, 0u, 15u); - curX = (int16_t)(curX + (int16_t)GLYPH_W); - } - p++; - } + gAgi->windowOpen = false; + if (gWinSave != NULL) { + windowBox(SPAN_RESTORE, 0u); + } +} + + +// 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. +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; + + if (col >= AGI_TEXT_COLS) { + 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++; + } +} + + +// The command line: the prompt (string 0), what has been typed and the +// cursor, on the input row. +void textDrawInputLine(void) { + uint8_t fg; + uint8_t bg; + uint8_t col; + uint8_t i; + + if (gAgi->textMode) { + 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; + } + 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; i < gAgi->inputLen && col < AGI_TEXT_COLS; i++) { + putCell(gAgi->inputRow, col++, gAgi->inputLine[i], fg, bg); + } + if (col < AGI_TEXT_COLS && gAgi->cursorChar != AGI_NO_CURSOR) { + putCell(gAgi->inputRow, col++, gAgi->cursorChar, fg, bg); + } + gEchoPos = (int16_t)((int16_t)gAgi->inputRow * AGI_TEXT_COLS + col); +} + + +// The status line: the score at the left and whether sound is on. +void textDrawStatus(void) { + char buf[AGI_TEXT_COLS + 1u]; + + if (gAgi->textMode || !gAgi->statusOn) { + return; + } + jlFillRect(jlStageGet(), 0, (int16_t)((int16_t)gAgi->statusRow * AGI_CELL_PX), (uint16_t)(AGI_TEXT_COLS * AGI_CELL_PX), AGI_CELL_PX, AGI_COLOR_WHITE); + snprintf(buf, sizeof(buf), "Score:%u of %u", (unsigned)gAgi->vars[VAR_SCORE], (unsigned)gAgi->vars[VAR_MAX_SCORE]); + drawText(gAgi->statusRow, STATUS_SCORE_COL, buf, AGI_COLOR_BLACK, AGI_COLOR_WHITE); + snprintf(buf, sizeof(buf), "Sound:%s", gAgi->flags[FLAG_SOUND] ? "on" : "off"); + drawText(gAgi->statusRow, STATUS_SOUND_COL, buf, AGI_COLOR_BLACK, AGI_COLOR_WHITE); + gShownScore = gAgi->vars[VAR_SCORE]; + gShownSound = gAgi->flags[FLAG_SOUND] != 0u; +} + + +// One edit of the command line, drawn as Sierra's interpreter draws it: +// only the cell typed or rubbed out and the cursor change, so anything +// else on the input row stays until the line is redrawn (Enter, +// accept.input). The echo goes on from where the last one left off, +// stepping back over the cursor first when there is a cursor: with +// AGI_QUIRK_ECHO_STEPS_BACK it does so even when the cursor was set after +// the line was drawn, and never drawn (measured on 2.435: a cursor set +// mid-line makes the next character overwrite the last one, and rubbing +// out can run back over the prompt); otherwise the echo starts from +// the end of what is typed. +void textEchoInput(uint8_t oldLen) { + uint8_t fg; + uint8_t bg; + bool cursor; + + if (gAgi->textMode || !gAgi->inputOn || oldLen == gAgi->inputLen) { + return; + } + 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)); + } + if (gAgi->inputLen > oldLen) { + if (cursor) { + gEchoPos--; + } + echoCell(gAgi->inputLine[oldLen], fg, bg); + if (cursor) { + echoCell(gAgi->cursorChar, fg, bg); + } + return; + } + if (cursor) { + gEchoPos = (int16_t)(gEchoPos - 2); + echoCell(gAgi->cursorChar, fg, bg); + } else { + gEchoPos--; + } + echoCell(' ', fg, bg); + gEchoPos--; +} + + +// Expand a message's format codes: %vN (a variable, %vN|W zero-padded to +// W digits), %sN (a string), %wN (the Nth word typed), %0N (an inventory +// item's name), %mN (a message of the running logic), %gN (a message of +// logic 0). +void textExpand(const char *src, char *dst, uint16_t dstCap) { + uint16_t pos; + + pos = 0u; + if (dstCap == 0u) { + return; + } + expandInto(src, dst, dstCap, &pos, 0u); +} + + +// graphics(): back from the text screen to the picture and its lines. +void textGraphicsMode(void) { + gAgi->textMode = false; + jlSurfaceClear(jlStageGet(), AGI_COLOR_BLACK); + gfxRestorePicture(); + textDrawStatus(); + textDrawInputLine(); +} + + +// status(): the inventory on a text screen, two columns of the items +// carried; with f13 set one can be picked into v25. +void textInventory(void) { + uint8_t carried[AGI_MAX_ITEMS]; + uint8_t count; + uint16_t i; + uint8_t sel; + uint8_t row; + uint8_t col; + uint16_t key; + bool selecting; + bool wasText; + + count = 0u; + for (i = 0u; i < gAgi->game.itemCount; i++) { + if (gAgi->itemRoom[i] == AGI_ITEM_CARRIED) { + carried[count++] = (uint8_t)i; + } + } + wasText = gAgi->textMode; + gAgi->textMode = true; + jlSurfaceClear(jlStageGet(), AGI_COLOR_WHITE); + drawText(0u, centerCol(kCarrying), kCarrying, AGI_COLOR_BLACK, AGI_COLOR_WHITE); + selecting = gAgi->flags[FLAG_ITEM_SELECT] != 0u && count > 0u; + sel = 0u; + for (;;) { + if (count == 0u) { + drawText(INVENTORY_FIRST_ROW, centerCol(kNothing), kNothing, AGI_COLOR_BLACK, AGI_COLOR_WHITE); + } + for (i = 0u; i < count; i++) { + const char *name = agiItemName(&gAgi->game, carried[i]); + bool hi = selecting && i == sel; + + row = (uint8_t)(INVENTORY_FIRST_ROW + i / INVENTORY_COLS); + col = (uint8_t)((i % INVENTORY_COLS == 0u) ? INVENTORY_LEFT_COL : INVENTORY_RIGHT_END - strlen(name)); + drawText(row, col, name, hi ? AGI_COLOR_WHITE : AGI_COLOR_BLACK, hi ? AGI_COLOR_BLACK : AGI_COLOR_WHITE); + } + drawText((uint8_t)(AGI_TEXT_ROWS - 1u), selecting ? INVENTORY_PICK_COL : INVENTORY_HINT_COL, + selecting ? "Press ENTER to select, ESC to cancel" : "Press a key to return to the game", + AGI_COLOR_BLACK, AGI_COLOR_WHITE); + key = inputWaitKey(); + if (!selecting) { + break; + } + if (key == AGI_KEY_ENTER) { + gAgi->vars[VAR_SELECTED_ITEM] = carried[sel]; + break; + } + if (key == AGI_KEY_ESCAPE) { + gAgi->vars[VAR_SELECTED_ITEM] = AGI_ITEM_CARRIED; + break; + } + if (key == AGI_KEY_RIGHT && sel + 1u < count) { + sel++; + } else if (key == AGI_KEY_LEFT && sel > 0u) { + sel--; + } else if (key == AGI_KEY_DOWN && sel + INVENTORY_COLS < count) { + sel = (uint8_t)(sel + INVENTORY_COLS); + } else if (key == AGI_KEY_UP && sel >= INVENTORY_COLS) { + sel = (uint8_t)(sel - INVENTORY_COLS); + } + } + gAgi->textMode = wasText; + if (!wasText) { + textGraphicsMode(); + } +} + + +void textMenuAdd(const char *name) { + AgiMenuT *m; + + if (gAgi->menuCount >= AGI_MAX_MENUS || gAgi->menuSubmitted) { + return; + } + m = &gAgi->menus[gAgi->menuCount]; + strncpy(m->name, name, AGI_MENU_NAME_MAX); + m->name[AGI_MENU_NAME_MAX] = '\0'; + m->firstItem = gAgi->menuItemCount; + m->itemCount = 0u; + gAgi->menuCount++; +} + + +void textMenuAddItem(const char *name, uint8_t controller) { + AgiMenuItemT *it; + + if (gAgi->menuCount == 0u || gAgi->menuItemCount >= AGI_MAX_MENU_ITEMS || gAgi->menuSubmitted) { + return; + } + it = &gAgi->menuItems[gAgi->menuItemCount]; + strncpy(it->text, name, AGI_MENU_ITEM_MAX); + it->text[AGI_MENU_ITEM_MAX] = '\0'; + it->controller = controller; + it->enabled = true; + gAgi->menuItemCount++; + gAgi->menus[gAgi->menuCount - 1u].itemCount++; +} + + +// menu.input: let the player pick from the menus; a chosen, enabled +// item's controller fires. Returns the controller, or 0xFF for none. +uint8_t textMenuInput(void) { + uint8_t menu; + uint8_t item; + uint16_t key; + uint8_t result; + jlSurfaceT *stage; + + if (gAgi->menuCount == 0u) { + return AGI_NO_CONTROLLER; + } + stage = jlStageGet(); + if (gWinSave == NULL) { + gWinSave = jlSurfaceCreate(); + } + if (gWinSave != NULL) { + jlSurfaceCopy(gWinSave, stage); + } + menu = gAgi->menuLastMenu; + item = 0u; + result = AGI_NO_CONTROLLER; + if (menu >= gAgi->menuCount) { + menu = 0u; + } + for (;;) { + if (gWinSave != NULL) { + jlSurfaceCopy(stage, gWinSave); + } + (void)drawMenuBar(menu); + drawMenuItems(menu, item); + key = inputWaitKey(); + if (key == AGI_KEY_ESCAPE) { + break; + } + if (key == AGI_KEY_ENTER) { + const AgiMenuItemT *it = &gAgi->menuItems[gAgi->menus[menu].firstItem + item]; + + if (it->enabled) { + result = it->controller; + break; + } + } else if (key == AGI_KEY_LEFT) { + menu = (menu == 0u) ? (uint8_t)(gAgi->menuCount - 1u) : (uint8_t)(menu - 1u); + item = 0u; + } else if (key == AGI_KEY_RIGHT) { + menu = (uint8_t)((menu + 1u) % gAgi->menuCount); + item = 0u; + } else if (key == AGI_KEY_UP) { + item = (item == 0u) ? (uint8_t)(gAgi->menus[menu].itemCount - 1u) : (uint8_t)(item - 1u); + } else if (key == AGI_KEY_DOWN) { + item = (uint8_t)((item + 1u) % gAgi->menus[menu].itemCount); + } + } + gAgi->menuLastMenu = menu; + if (gWinSave != NULL) { + jlSurfaceCopy(stage, gWinSave); + } + return result; +} + + +// A message window the player dismisses (or that times out). +void textMessageBox(const char *text) { + textPrint(text, -1, -1, 0u); + (void)inputWaitPrintClose(0u); + textCloseWindow(); +} + + +// Open a message window: centred over the picture when row and col are +// negative (print), else with its text at (row, col) of the picture area +// (print.at). width 0 means the default 30 columns. +void textPrint(const char *text, int16_t row, int16_t col, uint8_t width) { + uint8_t count; + uint8_t maxWidth; + uint8_t i; + + if (width == 0u) { + width = WINDOW_MAX_COLS; + } else if (width > AGI_TEXT_COLS) { + width = AGI_TEXT_COLS; + } + count = wrapText(text, width, gLines, WINDOW_MAX_LINES, &maxWidth); + if (maxWidth == 0u) { + maxWidth = 1u; + } + if (col < 0) { + col = (int16_t)((AGI_TEXT_COLS - maxWidth) / 2u); + } + if (row < 0) { + row = (int16_t)((AGI_HEIGHT / AGI_CELL_PX - count) / 2u); + } + row = (int16_t)(row + gAgi->picRow); + openWindow((int16_t)(col * AGI_PX_PER_CELL - WINDOW_BOX_LEFT), (int16_t)(row * AGI_CELL_PX - WINDOW_BOX_TOP), + (int16_t)((int16_t)maxWidth * AGI_PX_PER_CELL + WINDOW_BOX_EXTRA_W), + (int16_t)((int16_t)count * AGI_CELL_PX + WINDOW_BOX_EXTRA_H)); + for (i = 0u; i < count; i++) { + drawText((uint8_t)(row + i), (uint8_t)col, gLines[i], WINDOW_TEXT_FG, WINDOW_BG); + } +} + + +// set.text.attribute. Set on the picture, a background only says "white" +// (the text screen then clears to white and shows the foreground on it); +// set on the text screen the colours are taken as they are. +void textSetAttribute(uint8_t fg, uint8_t bg) { + gAgi->textFg = (uint8_t)(fg & AGI_COLOR_MASK); + gAgi->textBg = (uint8_t)(bg & AGI_COLOR_MASK); + if (!gAgi->textMode && gAgi->textBg != AGI_COLOR_BLACK) { + gAgi->textBg = AGI_COLOR_WHITE; + } +} + + +// show.obj: an inventory item's close-up view at the bottom of the +// picture with its description, until a key. +void textShowObject(uint8_t viewId) { + const AgiViewT *view; + char text[EXPAND_MAX]; + + view = agiViewGet(viewId); + if (view == NULL) { + return; + } + if (view->description != NULL) { + textExpand(view->description, text, sizeof(text)); + } else { + text[0] = '\0'; + } + gfxShowObjectCel(viewId); + textPrint(text, -1, -1, 0u); + (void)inputWaitPrintClose(0u); + textCloseWindow(); + gfxRestorePicture(); +} + + +// text.screen(): the 40x25 text screen in the attribute's background. +void textTextMode(void) { + textCloseWindow(); + gAgi->textMode = true; + jlSurfaceClear(jlStageGet(), gAgi->textBg); +} + + +// The status line again if the score or the sound setting has changed. +void textUpdateStatus(void) { + if (gShownScore != gAgi->vars[VAR_SCORE] || gShownSound != (gAgi->flags[FLAG_SOUND] != 0u)) { + textDrawStatus(); } } diff --git a/agiView.c b/agiView.c index 137d0d3..7e4f075 100644 --- a/agiView.c +++ b/agiView.c @@ -1,395 +1,198 @@ -// AGI v2 VIEW (sprite/animation) decoder. +// AGI v2 VIEW parsing (AGI specification chapter 8). // -// Parses an in-RAM VIEW resource into a structured loop/cel table -// once at load time, then renders any cel against the priority plane -// for per-pixel actor-vs-picture masking. Re-implements the public -// AGI VIEW byte layout from scratch. -// -// Pixel-doubled blit goes straight to the stage so animation cycles -// don't require an intermediate back-buffer. The 160x168 AGI surface -// maps onto stage columns (2 * agiX, 2 * agiX + 1) at the same destY -// agiPicBlit uses. +// A view is a header (loop count, description offset, a loop offset per +// loop), each loop a cel count and cel offsets relative to the loop, each +// cel a width, height and a byte holding the transparent colour (low +// nibble) and the mirroring bits (high nibble), then run-length rows: a +// byte of colour (high nibble) and run length (low nibble), 0 ending a +// row. Loops that mirror each other share their cels; a cel whose +// mirroring bit is set is drawn flipped in every loop except the one its +// loop field names. #include "agi.h" -#include "joey/draw.h" -#include "surfaceInternal.h" - #include -#include -#include + +#include "joey/core.h" -// VIEW header byte offsets (publicly documented v2 layout). -#define AGI_VIEW_HDR_LOOP_COUNT 2u -#define AGI_VIEW_HDR_LOOP_TABLE 5u - -// Cel header bits (publicly documented v2 layout). -#define AGI_CEL_TRANSPARENT_MASK 0x0Fu -#define AGI_CEL_MIRROR_FLAG 0x80u -#define AGI_CEL_MIRROR_SRC_SHIFT 4u -#define AGI_CEL_MIRROR_SRC_MASK 0x07u - -// Priority bands. Y 0..47 is sky (priority 4); Y 48..167 splits into -// 10 ground bands of 12 pixels each at priorities 5..14. Priority 15 -// is "always on top" and reserved for HUD/overlay; an actor's natural -// Y never reaches it. -#define AGI_PRI_SKY_TOP 48 -#define AGI_PRI_GROUND_FIRST 5u -#define AGI_PRI_GROUND_BAND_PX 12 - - -// ----- File-local types ----- - -// Per-draw invariants for one cel blit, grouped so they live in memory -// rather than competing for the w65816's tiny register file. Passing a -// pointer to this keeps the hot blit helpers under the backend's -// register-allocation ceiling. -typedef struct { - const uint8_t *picPriority; - int16_t x; - int16_t width; - uint8_t actorPri; - uint8_t transparent; - bool mirrored; -} AgiCelBlitT; +#define VIEW_LOOP_COUNT_BYTE 2u +#define VIEW_DESC_OFFSET_BYTE 3u +#define VIEW_LOOP_TABLE_BYTE 5u +#define CEL_HEADER_BYTES 3u +#define CEL_TRANSPARENT_MASK 0x0Fu +#define CEL_MIRRORED 0x80u +#define CEL_MIRROR_LOOP_SHIFT 4u +#define CEL_MIRROR_LOOP_MASK 0x07u // ----- Prototypes ----- -static void blitPlanar(const AgiCelBlitT *blit, const uint8_t *rle, int16_t height, int16_t topY, jlSurfaceT *stage, int16_t destY); -static const uint8_t *blitRowChunky(const AgiCelBlitT *blit, const uint8_t *rle, const uint8_t *priRow, uint8_t *stageRow); -static void markCelDirty(jlSurfaceT *stage, int16_t x, int16_t width, int16_t destY, int16_t topY, int16_t height); -static bool parseLoop(AgiLoopInfoT *loop, const uint8_t *loopStart, uint16_t maxBytes); -static const AgiCelInfoT *resolveMirror(const AgiViewT *view, uint8_t loopIdx, uint8_t celIdx, bool *outMirrored); +static bool celCountOf(const uint8_t *raw, uint16_t length, uint8_t loopCount, uint16_t *outCels); +static bool inUse(const AgiViewT *view); +static AgiViewT *load(const AgiGameT *game, uint8_t viewId); +static uint16_t readLe16(const uint8_t *p); // ----- Internal helpers (alphabetical) ----- -// Planar fallback row walker (stage->pixels == NULL). Routes every -// opaque, visible pixel through jlDrawPixel so the port's c2p / -// plane-sync path stays correct. Split out of agiViewDraw so its -// locals don't pile onto the chunky path's register budget. -static void blitPlanar(const AgiCelBlitT *blit, const uint8_t *rle, int16_t height, int16_t topY, jlSurfaceT *stage, int16_t destY) { - int16_t width; - int16_t cy; - int16_t cx; - int16_t drawCx; - int16_t agiX; - int16_t agiY; - int16_t stageX; - int16_t stageY; - uint8_t rleByte; - uint8_t color; - uint8_t runLen; - uint8_t r; - uint8_t picPri; +// Check the loop table and every cel header against the resource's length +// and count the cels, so the view can be built in one allocation. +static bool celCountOf(const uint8_t *raw, uint16_t length, uint8_t loopCount, uint16_t *outCels) { + uint8_t loop; + uint8_t cel; + uint8_t count; + uint16_t loopOffset; + uint16_t celOffset; - width = blit->width; - for (cy = 0; cy < height; cy++) { - cx = 0; - for (;;) { - rleByte = *rle++; - if (rleByte == 0u) { break; } - color = (uint8_t)(rleByte >> 4); - runLen = (uint8_t)(rleByte & 0x0Fu); - for (r = 0u; r < runLen; r++) { - if (cx >= width) { break; } - if (color != blit->transparent) { - drawCx = blit->mirrored ? (int16_t)(width - 1 - cx) : cx; - agiX = (int16_t)(blit->x + drawCx); - agiY = (int16_t)(topY + cy); - if (agiX >= 0 && agiX < (int16_t)AGI_PIC_WIDTH && agiY >= 0 && agiY < (int16_t)AGI_PIC_HEIGHT) { - picPri = blit->picPriority[agiY * (int16_t)AGI_PIC_WIDTH + agiX]; - if (picPri <= blit->actorPri) { - stageX = (int16_t)(agiX << 1); - stageY = (int16_t)(destY + agiY); - jlDrawPixel(stage, stageX, stageY, color); - jlDrawPixel(stage, (int16_t)(stageX + 1), stageY, color); - } - } - } - cx++; - } - } - } -} - - -// Chunky fast-path walker for one source row. Consumes the cel's RLE -// stream from `rle`, writing pixel-doubled bytes into `stageRow` where -// the picture priority allows it, and returns the advanced RLE pointer -// so the caller can hand it to the next row. A NULL priRow / stageRow -// means the row is clipped out vertically: the RLE is still walked (so -// the pointer lands correctly for later rows) but nothing is drawn. -static const uint8_t *blitRowChunky(const AgiCelBlitT *blit, const uint8_t *rle, const uint8_t *priRow, uint8_t *stageRow) { - int16_t width; - int16_t cx; - int16_t drawCx; - int16_t agiX; - uint8_t rleByte; - uint8_t color; - uint8_t runLen; - uint8_t packed; - uint8_t r; - - width = blit->width; - cx = 0; - for (;;) { - rleByte = *rle++; - if (rleByte == 0u) { break; } - color = (uint8_t)(rleByte >> 4); - runLen = (uint8_t)(rleByte & 0x0Fu); - // Transparent runs and vertically-clipped rows both just skip - // ahead by runLen without touching the stage. - if (color == blit->transparent || priRow == NULL) { - cx = (int16_t)(cx + runLen); - if (cx > width) { cx = width; } - continue; - } - packed = (uint8_t)((color << 4) | color); - for (r = 0u; r < runLen; r++) { - if (cx >= width) { break; } - drawCx = blit->mirrored ? (int16_t)(width - 1 - cx) : cx; - agiX = (int16_t)(blit->x + drawCx); - if (agiX >= 0 && agiX < (int16_t)AGI_PIC_WIDTH) { - if (priRow[agiX] <= blit->actorPri) { - stageRow[agiX] = packed; - } - } - cx++; - } - } - return rle; -} - - -// Mark the cel's stage bounding box dirty once after a chunky blit. -// Computes the pixel-doubled bbox from the source rect, clips it to the -// stage so the surface marker doesn't reject the whole rect for an -// off-screen edge, and records it if any of it is on-screen. -static void markCelDirty(jlSurfaceT *stage, int16_t x, int16_t width, int16_t destY, int16_t topY, int16_t height) { - int16_t minStageX; - int16_t maxStageX; - int16_t minStageY; - int16_t maxStageY; - - minStageX = (int16_t)(x << 1); - maxStageX = (int16_t)((x + width) << 1); - minStageY = (int16_t)(destY + topY); - maxStageY = (int16_t)(destY + topY + height); - if (minStageX < 0) { minStageX = 0; } - if (minStageY < 0) { minStageY = 0; } - if (maxStageX > (int16_t)SURFACE_WIDTH) { maxStageX = (int16_t)SURFACE_WIDTH; } - if (maxStageY > (int16_t)SURFACE_HEIGHT) { maxStageY = (int16_t)SURFACE_HEIGHT; } - if (maxStageX > minStageX && maxStageY > minStageY) { - surfaceMarkDirtyRect(stage, minStageX, minStageY, - (int16_t)(maxStageX - minStageX), - (int16_t)(maxStageY - minStageY)); - } -} - - -static bool parseLoop(AgiLoopInfoT *loop, const uint8_t *loopStart, uint16_t maxBytes) { - uint8_t celCount; - uint8_t i; - uint16_t celOffset; - const uint8_t *celStart; - uint8_t flags; - - if (maxBytes < 1u) { - return false; - } - celCount = loopStart[0]; - if ((uint16_t)celCount > AGI_MAX_CELS_PER_LOOP) { - return false; - } - if (maxBytes < (uint16_t)(1u + (uint16_t)celCount * 2u)) { - return false; - } - - loop->celCount = celCount; - for (i = 0u; i < celCount; i++) { - celOffset = (uint16_t)(loopStart[1u + (uint16_t)i * 2u] | - ((uint16_t)loopStart[2u + (uint16_t)i * 2u] << 8)); - if ((uint16_t)(celOffset + 3u) > maxBytes) { + *outCels = 0u; + for (loop = 0u; loop < loopCount; loop++) { + loopOffset = readLe16(&raw[VIEW_LOOP_TABLE_BYTE + (uint16_t)loop * 2u]); + if (loopOffset >= length) { return false; } - celStart = loopStart + celOffset; - flags = celStart[2]; - loop->cels[i].width = celStart[0]; - loop->cels[i].height = celStart[1]; - loop->cels[i].transparentColor = (uint8_t)(flags & AGI_CEL_TRANSPARENT_MASK); - loop->cels[i].mirrored = (uint8_t)((flags & AGI_CEL_MIRROR_FLAG) ? 1u : 0u); - loop->cels[i].mirrorSourceLoop = (uint8_t)((flags >> AGI_CEL_MIRROR_SRC_SHIFT) & AGI_CEL_MIRROR_SRC_MASK); - loop->cels[i].rleData = celStart + 3; + count = raw[loopOffset]; + if ((uint32_t)loopOffset + 1u + (uint32_t)count * 2u > length) { + return false; + } + for (cel = 0u; cel < count; cel++) { + celOffset = (uint16_t)(loopOffset + readLe16(&raw[loopOffset + 1u + (uint16_t)cel * 2u])); + if ((uint32_t)celOffset + CEL_HEADER_BYTES > length) { + return false; + } + } + *outCels = (uint16_t)(*outCels + count); } return true; } -// If the cel is a mirror, swap to the source loop's same-index cel and -// flag the caller to flip the RLE walk horizontally. Mirror chains -// don't recurse in valid AGI data; we don't follow more than one hop. -// -// Sierra's encoder marks every cel in BOTH halves of a mirror pair -// with the mirror flag, with the source-loop field pointing to the -// lower-numbered loop. The cel is only an actual mirror when the -// source-loop field points somewhere ELSE; when source == this loop, -// this IS the source data and must be drawn unflipped (otherwise both -// loops in a pair render mirrored and e.g. Graham faces left whether -// the player presses left or right). -static const AgiCelInfoT *resolveMirror(const AgiViewT *view, uint8_t loopIdx, uint8_t celIdx, bool *outMirrored) { - const AgiCelInfoT *cel; - uint8_t sourceLoop; +static bool inUse(const AgiViewT *view) { + uint8_t i; - if (loopIdx >= view->loopCount) { - return NULL; - } - if (celIdx >= view->loops[loopIdx].celCount) { - return NULL; - } - cel = &view->loops[loopIdx].cels[celIdx]; - *outMirrored = false; - if (cel->mirrored) { - sourceLoop = cel->mirrorSourceLoop; - if (sourceLoop != loopIdx && sourceLoop < view->loopCount && celIdx < view->loops[sourceLoop].celCount) { - cel = &view->loops[sourceLoop].cels[celIdx]; - *outMirrored = true; + for (i = 0u; i < gAgi->objectCount; i++) { + if (gAgi->objects[i].view == view) { + return true; } } - return cel; + return false; +} + + +// The view, its loop table and every cel header share one allocation (on +// the IIgs each is a Memory Manager handle); the cels point into raw. +static AgiViewT *load(const AgiGameT *game, uint8_t viewId) { + AgiViewT *view; + AgiCelT *cels; + uint8_t *raw; + uint16_t length; + uint16_t celTotal; + uint16_t loopOffset; + uint16_t celOffset; + uint16_t descOffset; + uint8_t loopCount; + uint8_t loop; + uint8_t cel; + + raw = agiResLoad(game, AGI_RES_VIEW, viewId, &length); + if (raw == NULL) { + return NULL; + } + loopCount = (length < VIEW_LOOP_TABLE_BYTE) ? 0u : raw[VIEW_LOOP_COUNT_BYTE]; + if (length < VIEW_LOOP_TABLE_BYTE || (uint32_t)VIEW_LOOP_TABLE_BYTE + (uint32_t)loopCount * 2u > length || + !celCountOf(raw, length, loopCount, &celTotal)) { + jlFree(raw); + return NULL; + } + view = (AgiViewT *)jlAlloc((uint32_t)sizeof(AgiViewT) + (uint32_t)loopCount * sizeof(AgiLoopT) + (uint32_t)celTotal * sizeof(AgiCelT)); + if (view == NULL) { + jlFree(raw); + return NULL; + } + view->raw = raw; + view->rawLength = length; + view->loopCount = loopCount; + view->loops = (AgiLoopT *)(view + 1); + view->description = NULL; + descOffset = readLe16(&raw[VIEW_DESC_OFFSET_BYTE]); + if (descOffset != 0u && descOffset < length) { + view->description = (const char *)&raw[descOffset]; + } + cels = (AgiCelT *)(view->loops + loopCount); + for (loop = 0u; loop < loopCount; loop++) { + loopOffset = readLe16(&raw[VIEW_LOOP_TABLE_BYTE + (uint16_t)loop * 2u]); + view->loops[loop].celCount = raw[loopOffset]; + view->loops[loop].cels = cels; + for (cel = 0u; cel < view->loops[loop].celCount; cel++) { + celOffset = (uint16_t)(loopOffset + readLe16(&raw[loopOffset + 1u + (uint16_t)cel * 2u])); + cels->width = raw[celOffset]; + cels->height = raw[celOffset + 1u]; + cels->transparent = (uint8_t)(raw[celOffset + 2u] & CEL_TRANSPARENT_MASK); + cels->mirrorFlags = (uint8_t)(raw[celOffset + 2u] & (uint8_t)~CEL_TRANSPARENT_MASK); + cels->rle = &raw[celOffset + CEL_HEADER_BYTES]; + cels++; + } + } + return view; +} + + +static uint16_t readLe16(const uint8_t *p) { + return (uint16_t)(p[0] | ((uint16_t)p[1] << 8)); } // ----- Public API (alphabetical) ----- -uint8_t agiActorPriorityForY(int16_t y) { - int16_t capped; - int16_t band; +// Look up a cel, clamping the loop and cel to the view's range. *outMirror +// says whether it is drawn flipped in this loop. +const AgiCelT *agiViewCel(const AgiViewT *view, uint8_t loop, uint8_t cel, bool *outMirror) { + const AgiCelT *c; - if (y < AGI_PRI_SKY_TOP) { - return 4u; + *outMirror = false; + if (view == NULL || view->loopCount == 0u) { + return NULL; } - capped = (y > (int16_t)(AGI_PIC_HEIGHT - 1)) ? (int16_t)(AGI_PIC_HEIGHT - 1) : y; - band = (int16_t)(AGI_PRI_GROUND_FIRST + (capped - AGI_PRI_SKY_TOP) / AGI_PRI_GROUND_BAND_PX); - if (band > 14) { - band = 14; + if (loop >= view->loopCount) { + loop = (uint8_t)(view->loopCount - 1u); } - return (uint8_t)band; + if (view->loops[loop].celCount == 0u) { + return NULL; + } + if (cel >= view->loops[loop].celCount) { + cel = (uint8_t)(view->loops[loop].celCount - 1u); + } + c = &view->loops[loop].cels[cel]; + if ((c->mirrorFlags & CEL_MIRRORED) != 0u && + ((c->mirrorFlags >> CEL_MIRROR_LOOP_SHIFT) & CEL_MIRROR_LOOP_MASK) != loop) { + *outMirror = true; + } + return c; } -void agiViewDraw(const AgiViewT *view, uint8_t loopIdx, uint8_t celIdx, - int16_t x, int16_t y, uint8_t actorPri, - const uint8_t *picPriority, - jlSurfaceT *stage, int16_t destY) { - const AgiCelInfoT *cel; - AgiCelBlitT blit; - const uint8_t *rle; - bool mirrored; - int16_t height; - int16_t topY; - uint8_t *stagePixels; +// new.room: free the views no object still shows (an unanimated object +// keeps its view for the next room, as ego does), as Sierra's interpreter +// frees the old room's resources. +void agiViewDiscardUnused(void) { + uint16_t i; - cel = resolveMirror(view, loopIdx, celIdx, &mirrored); - if (cel == NULL) { - return; - } - height = (int16_t)cel->height; - blit.picPriority = picPriority; - blit.x = x; - blit.width = (int16_t)cel->width; - blit.actorPri = actorPri; - blit.transparent = cel->transparentColor; - blit.mirrored = mirrored; - rle = cel->rleData; - topY = (int16_t)(y - height + 1); - stagePixels = (stage != NULL) ? stage->pixels : NULL; - - // Per-pixel chunky fast path: pixel-doubled output means each - // source pixel maps to exactly one stage byte (both nibbles = - // same color). We can skip jlDrawPixel and write the byte - // direct, marking the cel bbox dirty once at the end. ~80x - // faster on DOSBox @ 386SX-equivalent CPU cycles. - if (stagePixels != NULL) { - int16_t cy; - int16_t agiY; - const uint8_t *priRow; - uint8_t *stageRow; - - for (cy = 0; cy < height; cy++) { - agiY = (int16_t)(topY + cy); - // Hoist per-row work out of the per-pixel inner loop. - // For rows entirely outside the pic clip we still have to - // walk the RLE so the byte pointer ends in the right place - // for subsequent rows. - if (agiY < 0 || agiY >= (int16_t)AGI_PIC_HEIGHT) { - priRow = NULL; - stageRow = NULL; - } else { - priRow = picPriority + (int32_t)agiY * (int32_t)AGI_PIC_WIDTH; - stageRow = stagePixels + - (int32_t)(destY + agiY) * (int32_t)SURFACE_BYTES_PER_ROW; - } - rle = blitRowChunky(&blit, rle, priRow, stageRow); - } - markCelDirty(stage, x, blit.width, destY, topY, height); - return; - } - - // Planar fallback (s->pixels NULL: Amiga Phase 9). Goes through - // jlDrawPixel so the port's c2p / plane-sync path stays correct. - blitPlanar(&blit, rle, height, topY, stage, destY); -} - - -void agiViewFree(AgiViewT *view) { - if (view->raw != NULL) { - free(view->raw); - view->raw = NULL; - } - view->rawLength = 0u; - view->loopCount = 0u; -} - - -bool agiViewParse(AgiViewT *view, uint8_t *rawBytes, uint16_t length) { - uint8_t loopCount; - uint8_t i; - uint16_t loopOffset; - - memset(view, 0, sizeof(*view)); - if (length < AGI_VIEW_HDR_LOOP_TABLE) { - free(rawBytes); - return false; - } - - loopCount = rawBytes[AGI_VIEW_HDR_LOOP_COUNT]; - if ((uint16_t)loopCount > AGI_MAX_LOOPS_PER_VIEW) { - free(rawBytes); - return false; - } - if ((uint16_t)(AGI_VIEW_HDR_LOOP_TABLE + (uint16_t)loopCount * 2u) > length) { - free(rawBytes); - return false; - } - - view->raw = rawBytes; - view->rawLength = length; - view->loopCount = loopCount; - - for (i = 0u; i < loopCount; i++) { - loopOffset = (uint16_t)(rawBytes[AGI_VIEW_HDR_LOOP_TABLE + (uint16_t)i * 2u] | - ((uint16_t)rawBytes[AGI_VIEW_HDR_LOOP_TABLE + 1u + (uint16_t)i * 2u] << 8)); - if (loopOffset >= length) { - agiViewFree(view); - return false; - } - if (!parseLoop(&view->loops[i], rawBytes + loopOffset, (uint16_t)(length - loopOffset))) { - agiViewFree(view); - return false; + for (i = 0u; i < AGI_MAX_RESOURCES; i++) { + if (gAgi->views[i] != NULL && !inUse(gAgi->views[i])) { + jlFree(gAgi->views[i]->raw); + jlFree(gAgi->views[i]); + gAgi->views[i] = NULL; } } - return true; +} + + +// A view from the cache, loaded on first use; NULL when the game has none +// by that number. Within a room views stay loaded (discard.view is a +// no-op). +const AgiViewT *agiViewGet(uint8_t viewId) { + if (gAgi->views[viewId] == NULL) { + gAgi->views[viewId] = load(&gAgi->game, viewId); + } + return gAgi->views[viewId]; } diff --git a/agiVm.c b/agiVm.c index 4e7ec40..e767e02 100644 --- a/agiVm.c +++ b/agiVm.c @@ -1,2415 +1,1483 @@ -// AGI v2 LOGIC VM, Phase 1 sub-A: bytecode dispatch skeleton. +// The LOGIC interpreter (AGI specification chapters 4 and 6). // -// This pass implements the variable / flag opcodes (0x00-0x11) plus -// the basic header parser. Control flow (IF / ELSE / GOTO), test -// commands (equaln, isset, said, ...), and side-effecting opcodes -// (new.room, load.pic, animate.obj, ...) come in later sub-phases. -// Unknown opcodes halt cleanly with the offending byte recorded so -// host tests can report exactly where execution stopped. +// A logic resource is a 16-bit code length, the code, then the messages: +// a count, an end offset and an offset per message (counted from the byte +// after the count), the text XORed with "Avis Durgan" running across the +// whole block. Messages are decrypted when a logic loads so they can be +// used in place. // -// All algorithms here are re-implementations written against the -// published AGI v2 LOGIC bytecode specification; no third-party -// interpreter source is referenced. +// Code bytes below 0xFC are commands; 0xFF opens an if, whose tests run to +// the next 0xFF and are followed by the 16-bit distance to skip when they +// are false; 0xFE is a jump by a signed 16-bit distance (else and goto). +// Inside an if, 0xFD negates the next test and 0xFC brackets an or-group. +// Tests are evaluated left to right and stop as soon as the answer is +// known, as in Sierra's interpreter -- said() and have.key() have side +// effects, so this matters. +// +// call() runs a logic as a subroutine from its scan start (set.scan.start +// remembers where); a logic call() had to load counts as unloaded again +// after, forgetting its scan start (its bytes stay cached until new.room +// frees every logic but 0). new.room, restart, restore and quit stop all +// logic at once (gAgi->abortLogic). #include "agi.h" -#include "joey/core.h" -#include "joey/debug.h" - #include #include - -// LOGIC resource layout: bytes 0-1 are the little-endian length of -// the bytecode section, followed by `bytecodeLen` bytes of bytecode, -// followed by the message table. The VM walks only the bytecode. -#define AGI_LOGIC_HDR_BYTES 2u - -// Implemented action opcodes (sub-A: arithmetic and flags). -#define OP_RETURN 0x00u -#define OP_INCREMENT 0x01u -#define OP_DECREMENT 0x02u -#define OP_ASSIGNN 0x03u -#define OP_ASSIGNV 0x04u -#define OP_ADDN 0x05u -#define OP_ADDV 0x06u -#define OP_SUBN 0x07u -#define OP_SUBV 0x08u -#define OP_LINDIRECTV 0x09u -#define OP_RINDIRECT 0x0Au -#define OP_LINDIRECTN 0x0Bu -#define OP_SET 0x0Cu -#define OP_RESET 0x0Du -#define OP_TOGGLE 0x0Eu -#define OP_SETV 0x0Fu -#define OP_RESETV 0x10u -#define OP_TOGGLEV 0x11u - -// Control flow (sub-B). 0xFF as an action opcode introduces an IF -// expression; the test list is terminated by an inner 0xFF, followed -// by a 2-byte LE skip offset that jumps past the IF body when the -// test is false. 0xFE is an unconditional relative GOTO (signed 16 -// bit), used by the compiler to skip ELSE bodies and to form loops. -#define OP_GOTO 0xFEu -#define OP_IF 0xFFu - -// Test command IDs (sub-B). Only valid inside an IF test list. -#define TEST_EQUALN 0x01u -#define TEST_EQUALV 0x02u -#define TEST_LESSN 0x03u -#define TEST_LESSV 0x04u -#define TEST_GREATERN 0x05u -#define TEST_GREATERV 0x06u -#define TEST_ISSET 0x07u -#define TEST_ISSETV 0x08u -#define TEST_HAS 0x09u -#define TEST_OBJ_IN_ROOM 0x0Au -#define TEST_POSN 0x0Bu -#define TEST_CONTROLLER 0x0Cu -#define TEST_HAVE_KEY 0x0Du -#define TEST_SAID 0x0Eu -#define TEST_COMPARE_STRINGS 0x0Fu -#define TEST_OBJ_IN_BOX 0x10u -#define TEST_CENTER_POSN 0x11u -#define TEST_RIGHT_POSN 0x12u -#define TEST_MAX 0x13u - -// Markers inside an IF test list. -#define TEST_END_LIST 0xFFu -#define TEST_OR_TOGGLE 0xFCu -#define TEST_NOT 0xFDu - -// Side-effecting opcodes wired to host callbacks in sub-D. Each has -// a real case in the dispatch switch; the host's callback table -// decides what (if anything) actually happens for the call. -#define OP_NEW_ROOM 0x12u -#define OP_NEW_ROOM_V 0x13u -#define OP_LOAD_LOGIC 0x14u -#define OP_LOAD_LOGIC_V 0x15u -#define OP_CALL 0x16u -#define OP_CALL_V 0x17u -#define OP_LOAD_PIC 0x18u -#define OP_DRAW_PIC 0x19u -#define OP_SHOW_PIC 0x1Au -#define OP_DISCARD_PIC 0x1Bu -#define OP_OVERLAY_PIC 0x1Cu - -// Object-table opcodes (animate.obj, set.view, ..., release.priority). -// These mutate the per-object slots in vm->objects[N]; per-frame -// rendering and per-cycle animation tick are handled outside the VM. -#define OP_LOAD_VIEW 0x1Eu -#define OP_LOAD_VIEW_V 0x1Fu -#define OP_DISCARD_VIEW 0x20u -#define OP_ANIMATE_OBJ 0x21u -#define OP_UNANIMATE_ALL 0x22u -#define OP_DRAW 0x23u -#define OP_ERASE 0x24u -#define OP_POSITION 0x25u -#define OP_POSITION_V 0x26u -#define OP_GET_POSN 0x27u -#define OP_REPOSITION 0x28u -#define OP_SET_VIEW 0x29u -#define OP_SET_VIEW_V 0x2Au -#define OP_SET_LOOP 0x2Bu -#define OP_SET_LOOP_V 0x2Cu -#define OP_FIX_LOOP 0x2Du -#define OP_RELEASE_LOOP 0x2Eu -#define OP_SET_CEL 0x2Fu -#define OP_SET_CEL_V 0x30u -#define OP_LAST_CEL 0x31u -#define OP_CURRENT_CEL 0x32u -#define OP_CURRENT_LOOP 0x33u -#define OP_CURRENT_VIEW 0x34u -#define OP_NUMBER_OF_LOOPS 0x35u -#define OP_SET_PRIORITY 0x36u -#define OP_SET_PRIORITY_V 0x37u -#define OP_RELEASE_PRIORITY 0x38u -#define OP_GET_PRIORITY 0x39u -#define OP_STOP_UPDATE 0x3Au -#define OP_START_UPDATE 0x3Bu -#define OP_FORCE_UPDATE 0x3Cu -#define OP_IGNORE_HORIZON 0x3Du -#define OP_OBSERVE_HORIZON 0x3Eu -#define OP_SET_HORIZON 0x3Fu -#define OP_OBJECT_ON_WATER 0x40u -#define OP_OBJECT_ON_LAND 0x41u -#define OP_OBJECT_ON_ANYTHING 0x42u -#define OP_IGNORE_OBJS 0x43u -#define OP_OBSERVE_OBJS 0x44u -#define OP_DISTANCE 0x45u - -// Animation cycling. -#define OP_STOP_CYCLING 0x46u -#define OP_START_CYCLING 0x47u -#define OP_NORMAL_CYCLE 0x48u -#define OP_END_OF_LOOP 0x49u -#define OP_REVERSE_CYCLE 0x4Au -#define OP_REVERSE_LOOP 0x4Bu -#define OP_CYCLE_TIME 0x4Cu - -// Motion. -#define OP_STOP_MOTION 0x4Du -#define OP_START_MOTION 0x4Eu -#define OP_STEP_SIZE 0x4Fu -#define OP_STEP_TIME 0x50u -#define OP_MOVE_OBJ 0x51u -#define OP_MOVE_OBJ_V 0x52u -#define OP_FOLLOW_EGO 0x53u -#define OP_WANDER 0x54u -#define OP_NORMAL_MOTION 0x55u -#define OP_SET_DIR 0x56u -#define OP_GET_DIR 0x57u - -// Object collision (most stub no-op; engine doesn't model blocking). -#define OP_IGNORE_BLOCKS 0x58u -#define OP_OBSERVE_BLOCKS 0x59u -#define OP_BLOCK 0x5Au -#define OP_UNBLOCK 0x5Bu - -// Inventory. -#define OP_GET 0x5Cu -#define OP_GET_V 0x5Du -#define OP_DROP 0x5Eu -#define OP_PUT 0x5Fu -#define OP_PUT_V 0x60u -#define OP_GET_ROOM_V 0x61u - -// Sound (stub for now; satisfies "sound finished" flag immediately). -#define OP_LOAD_SOUND 0x62u -#define OP_SOUND 0x63u -#define OP_STOP_SOUND 0x64u - -// Text. -#define OP_PRINT 0x65u -#define OP_PRINT_V 0x66u -#define OP_DISPLAY 0x67u -#define OP_DISPLAY_V 0x68u -#define OP_CLEAR_LINES 0x69u -#define OP_TEXT_SCREEN 0x6Au -#define OP_GRAPHICS 0x6Bu -#define OP_SET_CURSOR_CHAR 0x6Cu -#define OP_SET_TEXT_ATTRIBUTE 0x6Du -#define OP_SHAKE_SCREEN 0x6Eu -#define OP_CONFIGURE_SCREEN 0x6Fu -#define OP_STATUS_LINE_ON 0x70u -#define OP_STATUS_LINE_OFF 0x71u -#define OP_SET_STRING 0x72u -#define OP_GET_STRING 0x73u -#define OP_WORD_TO_STRING 0x74u -#define OP_PARSE 0x75u -#define OP_GET_NUM 0x76u - -// Input. -#define OP_PREVENT_INPUT 0x77u -#define OP_ACCEPT_INPUT 0x78u -#define OP_SET_KEY 0x79u - -// Picture composition. -#define OP_ADD_TO_PIC 0x7Au -#define OP_ADD_TO_PIC_V 0x7Bu - -// Misc. -#define OP_STATUS 0x7Cu -#define OP_SAVE_GAME 0x7Du -#define OP_RESTORE_GAME 0x7Eu -#define OP_INIT_DISK 0x7Fu -#define OP_RESTART_GAME 0x80u -#define OP_SHOW_OBJ 0x81u -#define OP_RANDOM 0x82u -#define OP_PROGRAM_CONTROL 0x83u -#define OP_PLAYER_CONTROL 0x84u -#define OP_OBJ_STATUS_V 0x85u -#define OP_QUIT 0x86u -#define OP_SHOW_MEM 0x87u -#define OP_PAUSE 0x88u -#define OP_ECHO_LINE 0x89u -#define OP_CANCEL_LINE 0x8Au -#define OP_INIT_JOY 0x8Bu -#define OP_TOGGLE_MONITOR 0x8Cu -#define OP_VERSION 0x8Du -#define OP_SCRIPT_SIZE 0x8Eu -#define OP_SET_GAME_ID 0x8Fu -#define OP_LOG 0x90u -#define OP_SET_SCAN_START 0x91u -#define OP_RESET_SCAN_START 0x92u -#define OP_REPOSITION_TO 0x93u -#define OP_REPOSITION_TO_V 0x94u -#define OP_TRACE_ON 0x95u -#define OP_TRACE_INFO 0x96u -#define OP_PRINT_AT 0x97u -#define OP_PRINT_AT_V 0x98u -#define OP_DISCARD_VIEW_V 0x99u -#define OP_CLEAR_TEXT_RECT 0x9Au -#define OP_SET_UPPER_LEFT 0x9Bu -#define OP_SET_MENU 0x9Cu -#define OP_SET_MENU_ITEM 0x9Du -#define OP_SUBMIT_MENU 0x9Eu -#define OP_ENABLE_ITEM 0x9Fu -#define OP_DISABLE_ITEM 0xA0u -#define OP_MENU_INPUT 0xA1u -#define OP_SHOW_OBJ_V 0xA2u -#define OP_OPEN_DIALOGUE 0xA3u -#define OP_CLOSE_DIALOGUE 0xA4u -#define OP_MUL_N 0xA5u -#define OP_MUL_V 0xA6u -#define OP_DIV_N 0xA7u -#define OP_DIV_V 0xA8u -#define OP_CLOSE_WINDOW 0xA9u -#define OP_SET_SIMPLE 0xAAu - -// Special vars touched by the engine outside script control. -#define ENG_VAR_EGO_DIR 6u -#define ENG_VAR_EGO_VIEW 16u - -// Inventory state lives in vm->vars[88..127] (40 slots) — each slot -// stores the room id where item N is currently (255 = ego inventory). -// AGI v2 fixes the inventory length to about 40 in most games; we -// reserve more (88..255) but cap at 64 items in practice. -#define INV_ROOM_INVENTORY 255u - -// Sentinel value in kActionArgBytes meaning "no v2 opcode at this -// index"; the dispatcher halts when it sees this in the table. -#define ACTION_UNKNOWN 0xFFu +#include "joey/core.h" +#include "joey/debug.h" -// Result of one opcode-group handler in the split agiVmRun dispatch. -// VM_DISPATCH_CONTINUE: opcode handled, advance to the next. RETURN: -// handled, stop and let agiVmRun return vm->haltReason. UNHANDLED: opcode -// is owned by a later group's handler. -typedef enum { - VM_DISPATCH_CONTINUE = 0, - VM_DISPATCH_RETURN = 1, - VM_DISPATCH_UNHANDLED = 2 -} VmDispatchE; +#define LOGIC_HEADER_BYTES 2u +#define MSG_HEADER_BYTES 3u +#define MSG_OFFSET_BYTES 2u +#define MSG_OFFSET_BASE 1u +#define SAID_WORD_BYTES 2u +#define CODE_IF 0xFFu +#define CODE_GOTO 0xFEu +#define CODE_NOT 0xFDu +#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), +// which Sierra's interpreter hangs on. JoeyAGI stops the chain instead. +#define CALL_DEPTH_MAX 32u + +// Action commands, by number (specification 6.2). +#define CMD_RETURN 0x00u +#define CMD_INCREMENT 0x01u +#define CMD_DECREMENT 0x02u +#define CMD_ASSIGNN 0x03u +#define CMD_ASSIGNV 0x04u +#define CMD_ADDN 0x05u +#define CMD_ADDV 0x06u +#define CMD_SUBN 0x07u +#define CMD_SUBV 0x08u +#define CMD_LINDIRECTV 0x09u +#define CMD_RINDIRECT 0x0Au +#define CMD_LINDIRECTN 0x0Bu +#define CMD_SET 0x0Cu +#define CMD_RESET 0x0Du +#define CMD_TOGGLE 0x0Eu +#define CMD_SET_V 0x0Fu +#define CMD_RESET_V 0x10u +#define CMD_TOGGLE_V 0x11u +#define CMD_NEW_ROOM 0x12u +#define CMD_NEW_ROOM_V 0x13u +#define CMD_LOAD_LOGICS 0x14u +#define CMD_LOAD_LOGICS_V 0x15u +#define CMD_CALL 0x16u +#define CMD_CALL_V 0x17u +#define CMD_LOAD_PIC 0x18u +#define CMD_DRAW_PIC 0x19u +#define CMD_SHOW_PIC 0x1Au +#define CMD_DISCARD_PIC 0x1Bu +#define CMD_OVERLAY_PIC 0x1Cu +#define CMD_SHOW_PRI_SCREEN 0x1Du +#define CMD_LOAD_VIEW 0x1Eu +#define CMD_LOAD_VIEW_V 0x1Fu +#define CMD_DISCARD_VIEW 0x20u +#define CMD_ANIMATE_OBJ 0x21u +#define CMD_UNANIMATE_ALL 0x22u +#define CMD_DRAW 0x23u +#define CMD_ERASE 0x24u +#define CMD_POSITION 0x25u +#define CMD_POSITION_V 0x26u +#define CMD_GET_POSN 0x27u +#define CMD_REPOSITION 0x28u +#define CMD_SET_VIEW 0x29u +#define CMD_SET_VIEW_V 0x2Au +#define CMD_SET_LOOP 0x2Bu +#define CMD_SET_LOOP_V 0x2Cu +#define CMD_FIX_LOOP 0x2Du +#define CMD_RELEASE_LOOP 0x2Eu +#define CMD_SET_CEL 0x2Fu +#define CMD_SET_CEL_V 0x30u +#define CMD_LAST_CEL 0x31u +#define CMD_CURRENT_CEL 0x32u +#define CMD_CURRENT_LOOP 0x33u +#define CMD_CURRENT_VIEW 0x34u +#define CMD_NUMBER_OF_LOOPS 0x35u +#define CMD_SET_PRIORITY 0x36u +#define CMD_SET_PRIORITY_V 0x37u +#define CMD_RELEASE_PRIORITY 0x38u +#define CMD_GET_PRIORITY 0x39u +#define CMD_STOP_UPDATE 0x3Au +#define CMD_START_UPDATE 0x3Bu +#define CMD_FORCE_UPDATE 0x3Cu +#define CMD_IGNORE_HORIZON 0x3Du +#define CMD_OBSERVE_HORIZON 0x3Eu +#define CMD_SET_HORIZON 0x3Fu +#define CMD_OBJECT_ON_WATER 0x40u +#define CMD_OBJECT_ON_LAND 0x41u +#define CMD_OBJECT_ON_ANYTHING 0x42u +#define CMD_IGNORE_OBJS 0x43u +#define CMD_OBSERVE_OBJS 0x44u +#define CMD_DISTANCE 0x45u +#define CMD_STOP_CYCLING 0x46u +#define CMD_START_CYCLING 0x47u +#define CMD_NORMAL_CYCLE 0x48u +#define CMD_END_OF_LOOP 0x49u +#define CMD_REVERSE_CYCLE 0x4Au +#define CMD_REVERSE_LOOP 0x4Bu +#define CMD_CYCLE_TIME 0x4Cu +#define CMD_STOP_MOTION 0x4Du +#define CMD_START_MOTION 0x4Eu +#define CMD_STEP_SIZE 0x4Fu +#define CMD_STEP_TIME 0x50u +#define CMD_MOVE_OBJ 0x51u +#define CMD_MOVE_OBJ_V 0x52u +#define CMD_FOLLOW_EGO 0x53u +#define CMD_WANDER 0x54u +#define CMD_NORMAL_MOTION 0x55u +#define CMD_SET_DIR 0x56u +#define CMD_GET_DIR 0x57u +#define CMD_IGNORE_BLOCKS 0x58u +#define CMD_OBSERVE_BLOCKS 0x59u +#define CMD_BLOCK 0x5Au +#define CMD_UNBLOCK 0x5Bu +#define CMD_GET 0x5Cu +#define CMD_GET_V 0x5Du +#define CMD_DROP 0x5Eu +#define CMD_PUT 0x5Fu +#define CMD_PUT_V 0x60u +#define CMD_GET_ROOM_V 0x61u +#define CMD_LOAD_SOUND 0x62u +#define CMD_SOUND 0x63u +#define CMD_STOP_SOUND 0x64u +#define CMD_PRINT 0x65u +#define CMD_PRINT_V 0x66u +#define CMD_DISPLAY 0x67u +#define CMD_DISPLAY_V 0x68u +#define CMD_CLEAR_LINES 0x69u +#define CMD_TEXT_SCREEN 0x6Au +#define CMD_GRAPHICS 0x6Bu +#define CMD_SET_CURSOR_CHAR 0x6Cu +#define CMD_SET_TEXT_ATTRIBUTE 0x6Du +#define CMD_SHAKE_SCREEN 0x6Eu +#define CMD_CONFIGURE_SCREEN 0x6Fu +#define CMD_STATUS_LINE_ON 0x70u +#define CMD_STATUS_LINE_OFF 0x71u +#define CMD_SET_STRING 0x72u +#define CMD_GET_STRING 0x73u +#define CMD_WORD_TO_STRING 0x74u +#define CMD_PARSE 0x75u +#define CMD_GET_NUM 0x76u +#define CMD_PREVENT_INPUT 0x77u +#define CMD_ACCEPT_INPUT 0x78u +#define CMD_SET_KEY 0x79u +#define CMD_ADD_TO_PIC 0x7Au +#define CMD_ADD_TO_PIC_V 0x7Bu +#define CMD_STATUS 0x7Cu +#define CMD_SAVE_GAME 0x7Du +#define CMD_RESTORE_GAME 0x7Eu +#define CMD_INIT_DISK 0x7Fu +#define CMD_RESTART_GAME 0x80u +#define CMD_SHOW_OBJ 0x81u +#define CMD_RANDOM 0x82u +#define CMD_PROGRAM_CONTROL 0x83u +#define CMD_PLAYER_CONTROL 0x84u +#define CMD_OBJ_STATUS_V 0x85u +#define CMD_QUIT 0x86u +#define CMD_SHOW_MEM 0x87u +#define CMD_PAUSE 0x88u +#define CMD_ECHO_LINE 0x89u +#define CMD_CANCEL_LINE 0x8Au +#define CMD_INIT_JOY 0x8Bu +#define CMD_TOGGLE_MONITOR 0x8Cu +#define CMD_VERSION 0x8Du +#define CMD_SCRIPT_SIZE 0x8Eu +#define CMD_SET_GAME_ID 0x8Fu +#define CMD_LOG 0x90u +#define CMD_SET_SCAN_START 0x91u +#define CMD_RESET_SCAN_START 0x92u +#define CMD_REPOSITION_TO 0x93u +#define CMD_REPOSITION_TO_V 0x94u +#define CMD_TRACE_ON 0x95u +#define CMD_TRACE_INFO 0x96u +#define CMD_PRINT_AT 0x97u +#define CMD_PRINT_AT_V 0x98u +#define CMD_DISCARD_VIEW_V 0x99u +#define CMD_CLEAR_TEXT_RECT 0x9Au +#define CMD_SET_UPPER_LEFT 0x9Bu +#define CMD_SET_MENU 0x9Cu +#define CMD_SET_MENU_ITEM 0x9Du +#define CMD_SUBMIT_MENU 0x9Eu +#define CMD_ENABLE_ITEM 0x9Fu +#define CMD_DISABLE_ITEM 0xA0u +#define CMD_MENU_INPUT 0xA1u +#define CMD_SHOW_OBJ_V 0xA2u +#define CMD_OPEN_DIALOGUE 0xA3u +#define CMD_CLOSE_DIALOGUE 0xA4u +#define CMD_MUL_N 0xA5u +#define CMD_MUL_V 0xA6u +#define CMD_DIV_N 0xA7u +#define CMD_DIV_V 0xA8u +#define CMD_CLOSE_WINDOW 0xA9u +#define CMD_COUNT 0xAAu + +// Test commands (specification 6.2). +#define TEST_EQUALN 0x01u +#define TEST_EQUALV 0x02u +#define TEST_LESSN 0x03u +#define TEST_LESSV 0x04u +#define TEST_GREATERN 0x05u +#define TEST_GREATERV 0x06u +#define TEST_ISSET 0x07u +#define TEST_ISSET_V 0x08u +#define TEST_HAS 0x09u +#define TEST_OBJ_IN_ROOM 0x0Au +#define TEST_POSN 0x0Bu +#define TEST_CONTROLLER 0x0Cu +#define TEST_HAVE_KEY 0x0Du +#define TEST_SAID 0x0Eu +#define TEST_COMPARE_STRINGS 0x0Fu +#define TEST_OBJ_IN_BOX 0x10u +#define TEST_CENTER_POSN 0x11u +#define TEST_RIGHT_POSN 0x12u +#define TEST_COUNT 0x13u + + +typedef struct { + const uint8_t *code; + uint16_t length; + uint16_t pc; + uint8_t logic; + bool done; +} VmFrameT; // ----- Prototypes ----- -static bool enterLogic(AgiVmT *vm, uint8_t logicId); -static bool evalOneTest(AgiVmT *vm, uint8_t testOp); -static bool evalTests(AgiVmT *vm); -static bool executeGoto(AgiVmT *vm); -static bool executeIf(AgiVmT *vm); -static void expandMessage(AgiVmT *vm, const char *src, char *dst, uint16_t dstCap); -static const char *fetchMessageOrEmpty(AgiVmT *vm, uint8_t logicId, uint8_t msgId); -static void openPrintModal(AgiVmT *vm, const char *expanded); -static bool popFrame(AgiVmT *vm); -static bool pushFrame(AgiVmT *vm); -static bool readByte(AgiVmT *vm, uint8_t *out); -static bool readU16(AgiVmT *vm, uint16_t *out); -static bool saidMatch(const AgiVmT *vm, const uint16_t *pat, uint8_t patCount); -static VmDispatchE vmStepCore(AgiVmT *vm, uint8_t op); -static VmDispatchE vmStepMisc(AgiVmT *vm, uint8_t op); -static VmDispatchE vmStepObject(AgiVmT *vm, uint8_t op); -static VmDispatchE vmStepText(AgiVmT *vm, uint8_t op); -static void writeTextRow(AgiVmT *vm, uint8_t row, uint8_t col, const char *expanded); +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); +static void cmdVars(VmFrameT *f, uint8_t op, const uint8_t *a); +static uint16_t codeLength(const uint8_t *raw); +static bool compareStrings(const char *a, const char *b); +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 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); +static uint8_t readByte(VmFrameT *f); +static uint16_t readWord(VmFrameT *f); +static void redrawObjects(uint8_t n, uint16_t setFlags, uint16_t clearFlags); +static bool saidMatch(VmFrameT *f); +static void skipTest(VmFrameT *f, uint8_t test); -// Per-action-opcode argument byte counts, indexed by opcode id. The -// real handlers (arithmetic / flags / IF / GOTO / new.room) consult -// this only as a sanity check; unrecognized opcodes (entries set to -// ACTION_UNKNOWN) halt the VM with AGI_VM_HALT_UNKNOWN_OP. Every other -// entry is treated as a stub: its argument bytes are consumed and no -// side effect is taken. This lets the VM walk arbitrary v2 logic -// without halting at every yet-to-be-implemented side-effecting -// command (load.pic / animate.obj / sound / ...). -static const uint8_t kActionArgBytes[256] = { - /* 0x00 */ 0u, 1u, 1u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 1u, 1u, 1u, 1u, - /* 0x10 */ 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 0u, 1u, 1u, 0u, 1u, 1u, - /* 0x20 */ 1u, 1u, 0u, 1u, 1u, 3u, 3u, 3u, 3u, 2u, 2u, 2u, 2u, 1u, 1u, 2u, - /* 0x30 */ 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 1u, 2u, 1u, 1u, 1u, 1u, 1u, 1u, - /* 0x40 */ 1u, 1u, 1u, 1u, 1u, 3u, 1u, 1u, 1u, 2u, 1u, 2u, 2u, 1u, 1u, 2u, - /* 0x50 */ 2u, 5u, 5u, 3u, 1u, 1u, 2u, 2u, 1u, 1u, 4u, 0u, 1u, 1u, 1u, 2u, - /* 0x60 */ 2u, 2u, 1u, 2u, 0u, 1u, 1u, 3u, 3u, 3u, 0u, 0u, 1u, 2u, 1u, 3u, - /* 0x70 */ 0u, 0u, 2u, 5u, 2u, 1u, 2u, 0u, 0u, 3u, 7u, 7u, 0u, 0u, 0u, 0u, - /* 0x80 */ 0u, 1u, 3u, 0u, 0u, 1u, 1u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 1u, 1u, - /* 0x90 */ 1u, 0u, 0u, 3u, 3u, 0u, 3u, 4u, 4u, 1u, 4u, 2u, 1u, 2u, 0u, 1u, - /* 0xA0 */ 1u, 0u, 1u, 0u, 0u, 2u, 2u, 2u, 2u, 0u, 1u, 0u, 0u, 0u, 1u, 1u, - /* 0xB0 */ 0u, 1u, 0u, 4u, 2u, 0u, 0u, - ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xBA */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xC0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xC8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xD0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xD8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xE0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xE8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xF0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xF8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN +// ----- Command tables ----- + +// Argument bytes per action command (specification 6.2). +static const uint8_t kActionArgs[CMD_COUNT] = { + 0, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, // 0x00 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, // 0x10 + 1, 1, 0, 1, 1, 3, 3, 3, 3, 2, 2, 2, 2, 1, 1, 2, // 0x20 + 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 1, 1, 1, 1, 1, 1, // 0x30 + 1, 1, 1, 1, 1, 3, 1, 1, 1, 2, 1, 2, 2, 1, 1, 2, // 0x40 + 2, 5, 5, 3, 1, 1, 2, 2, 1, 1, 4, 0, 1, 1, 1, 2, // 0x50 + 2, 2, 1, 2, 0, 1, 1, 3, 3, 3, 0, 0, 1, 2, 1, 3, // 0x60 + 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 +}; + +// 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 }; -// Per-test-command argument byte counts, indexed by test opcode id. -// 0x0E (said) is variable-length and gets special-cased rather than -// indexed via this table. -static const uint8_t kTestArgBytes[TEST_MAX] = { - /* 0x00 */ 0u, - /* 0x01 equaln */ 2u, - /* 0x02 equalv */ 2u, - /* 0x03 lessn */ 2u, - /* 0x04 lessv */ 2u, - /* 0x05 greatern */ 2u, - /* 0x06 greaterv */ 2u, - /* 0x07 isset */ 1u, - /* 0x08 issetv */ 1u, - /* 0x09 has */ 1u, - /* 0x0A obj.in.room */ 2u, - /* 0x0B posn */ 5u, - /* 0x0C controller */ 1u, - /* 0x0D have.key */ 0u, - /* 0x0E said */ 0u, - /* 0x0F compare.strings */ 2u, - /* 0x10 obj.in.box */ 5u, - /* 0x11 center.posn */ 5u, - /* 0x12 right.posn */ 5u -}; +// ----- Module state ----- + +// How deep call() has nested (a logic that calls itself forever stops). +static uint8_t gCallDepth; // ----- Internal helpers (alphabetical) ----- -// Switch the VM's running logic to `logicId`. Used by OP_CALL and -// OP_CALL_V after pushFrame has saved the caller's position. Returns -// false (and sets haltReason) if the host has no bytecode for the -// requested id. -static bool enterLogic(AgiVmT *vm, uint8_t logicId) { - const uint8_t *bytecode; - uint16_t length; - - if (vm->callbacks.fetchLogic == NULL) { - vm->haltReason = AGI_VM_HALT_NO_LOGIC; - return false; - } - length = 0u; - bytecode = vm->callbacks.fetchLogic(vm->callbacks.ctx, logicId, &length); - if (bytecode == NULL || length == 0u) { - vm->haltReason = AGI_VM_HALT_NO_LOGIC; - return false; - } - vm->code = bytecode; - vm->codeLength = length; - vm->pc = 0u; - vm->currentLogicId = logicId; - return true; -} - - -// Evaluate a single test command, with pc currently positioned at -// the first argument byte. Always consumes the test's full argument -// list so the caller's stream position stays in sync, even when the -// test is stubbed to a constant false. Tests this sub-phase doesn't -// implement (objects, parser, controllers, key state) read their -// args and return false; the VM walks past the IF body as if the -// test evaluated against fresh game state. -static bool evalOneTest(AgiVmT *vm, uint8_t testOp) { - uint8_t args[5]; - uint8_t i; - uint8_t argBytes; - - if (testOp == 0u || testOp >= TEST_MAX) { - vm->lastUnknownOp = testOp; - vm->haltReason = AGI_VM_HALT_UNKNOWN_OP; - return false; - } - - if (testOp == TEST_SAID) { - // Variable-length: 1-byte word count, then that many 16-bit - // little-endian word IDs. Read the pattern (always, to keep pc in - // sync) then match it against what the player typed this cycle. - uint16_t pat[AGI_MAX_PARSED]; - uint8_t count; - uint8_t lo; - uint8_t hi; - uint8_t k; - bool matched; - - if (!readByte(vm, &count)) { - return false; - } - for (k = 0u; k < count; k++) { - if (!readByte(vm, &lo) || !readByte(vm, &hi)) { - return false; +// The rest: system, screen, menu and arithmetic commands (0x7C..0xA9). +static void cmdMisc(VmFrameT *f, uint8_t op, const uint8_t *a) { + switch (op) { + case CMD_STATUS: + textInventory(); + break; + case CMD_SAVE_GAME: + saveGame(); + break; + case CMD_RESTORE_GAME: + if (restoreGame()) { + gAgi->abortLogic = true; } - if (k < AGI_MAX_PARSED) { - pat[k] = (uint16_t)((uint16_t)lo | ((uint16_t)hi << 8)); - } - } - // said() fires only on the cycle input was entered; a match consumes - // that input (clears flag 2) so no later said() double-handles it. - if (vm->flags[AGI_FLAG_INPUT] == 0u || count > AGI_MAX_PARSED) { - return false; - } - matched = saidMatch(vm, pat, count); - if (matched) { - vm->flags[AGI_FLAG_INPUT] = 0u; - } - return matched; - } - - argBytes = kTestArgBytes[testOp]; - for (i = 0u; i < argBytes; i++) { - if (!readByte(vm, &args[i])) { - return false; - } - } - - switch (testOp) { - case TEST_EQUALN: return vm->vars[args[0]] == args[1]; - case TEST_EQUALV: return vm->vars[args[0]] == vm->vars[args[1]]; - case TEST_LESSN: return vm->vars[args[0]] < args[1]; - case TEST_LESSV: return vm->vars[args[0]] < vm->vars[args[1]]; - case TEST_GREATERN: return vm->vars[args[0]] > args[1]; - case TEST_GREATERV: return vm->vars[args[0]] > vm->vars[args[1]]; - case TEST_ISSET: return vm->flags[args[0]] != 0u; - case TEST_ISSETV: return vm->flags[vm->vars[args[0]]] != 0u; - case TEST_HAVE_KEY: - if (vm->callbacks.haveKey != NULL) { - return vm->callbacks.haveKey(vm->callbacks.ctx); - } - return false; - case TEST_CONTROLLER: { - uint8_t c = args[0]; - if (c < AGI_MAX_CONTROLLERS && vm->controllerFired[c] != 0u) { - vm->controllerFired[c] = 0u; - return true; - } - return false; - } - case TEST_POSN: { - uint8_t n = args[0]; - int16_t x1 = (int16_t)args[1]; - int16_t y1 = (int16_t)args[2]; - int16_t x2 = (int16_t)args[3]; - int16_t y2 = (int16_t)args[4]; - const AgiObjectT *o; - - if (n >= AGI_MAX_OBJECTS) return false; - o = &vm->objects[n]; - return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2); - } - case TEST_OBJ_IN_BOX: { - // Same shape as posn but checks the *entire* cel bbox is - // inside the box. Without view dimensions in the VM we - // approximate with point containment (== posn). - uint8_t n = args[0]; - int16_t x1 = (int16_t)args[1]; - int16_t y1 = (int16_t)args[2]; - int16_t x2 = (int16_t)args[3]; - int16_t y2 = (int16_t)args[4]; - const AgiObjectT *o; - - if (n >= AGI_MAX_OBJECTS) return false; - o = &vm->objects[n]; - return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2); - } - case TEST_CENTER_POSN: { - uint8_t n = args[0]; - int16_t x1 = (int16_t)args[1]; - int16_t y1 = (int16_t)args[2]; - int16_t x2 = (int16_t)args[3]; - int16_t y2 = (int16_t)args[4]; - const AgiObjectT *o; - - if (n >= AGI_MAX_OBJECTS) return false; - o = &vm->objects[n]; - return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2); - } - case TEST_RIGHT_POSN: { - uint8_t n = args[0]; - int16_t x1 = (int16_t)args[1]; - int16_t y1 = (int16_t)args[2]; - int16_t x2 = (int16_t)args[3]; - int16_t y2 = (int16_t)args[4]; - const AgiObjectT *o; - - if (n >= AGI_MAX_OBJECTS) return false; - o = &vm->objects[n]; - return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2); - } - case TEST_HAS: - // Inventory: var[88+arg] is the item room (255 = held). - // Without OBJECT-table parsing, default to "not held". - return false; - case TEST_OBJ_IN_ROOM: - // Same: defaults to false until OBJECT support lands. - return false; - default: return false; - } -} - - -// Walk the IF test list at pc. Default operator is AND; 0xFC toggles -// in/out of an OR group whose result is then ANDed into the running -// AND. 0xFD prefixes the next test with NOT. 0xFF terminates the -// list. Leaves pc positioned right after the terminator so the -// caller can read the 2-byte skip offset. -static bool evalTests(AgiVmT *vm) { - bool andResult; - bool orResult; - bool inOrGroup; - bool notNext; - bool testResult; - uint8_t op; - - andResult = true; - orResult = false; - inOrGroup = false; - notNext = false; - - for (;;) { - if (!readByte(vm, &op)) { - return false; - } - if (op == TEST_END_LIST) { - if (inOrGroup) { - andResult = (bool)(andResult && orResult); - } - return andResult; - } - if (op == TEST_OR_TOGGLE) { - if (inOrGroup) { - andResult = (bool)(andResult && orResult); - inOrGroup = false; + break; + case CMD_RESTART_GAME: + agiRestartGame(); + break; + case CMD_SHOW_OBJ: + textShowObject(a[0]); + break; + case CMD_SHOW_OBJ_V: + 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 { - orResult = false; - inOrGroup = true; + gAgi->vars[a[2]] = a[0]; } - continue; - } - if (op == TEST_NOT) { - notNext = (bool)!notNext; - continue; - } + break; + case CMD_PROGRAM_CONTROL: + gAgi->playerControl = false; + break; + case CMD_PLAYER_CONTROL: + gAgi->playerControl = true; + gAgi->objects[AGI_EGO].motion = AGI_MOTION_NORMAL; + break; + case CMD_OBJ_STATUS_V: + agiObjectStatus(gAgi->vars[a[0]]); + break; + case CMD_QUIT: + agiQuit(a[0] != 0u); + break; + case CMD_SHOW_MEM: + textMessageBox("Memory is not a problem here."); + break; + case CMD_PAUSE: + textMessageBox(" Game paused.\nPress Enter to continue."); + break; + case CMD_ECHO_LINE: + if (gAgi->inputLen == 0u) { + strcpy(gAgi->inputLine, gAgi->lastLine); + gAgi->inputLen = (uint8_t)strlen(gAgi->inputLine); + textDrawInputLine(); + } + break; + case CMD_CANCEL_LINE: + gAgi->inputLine[0] = '\0'; + gAgi->inputLen = 0u; + textDrawInputLine(); + break; + case CMD_VERSION: + textMessageBox("JoeyAGI\n\nan AGI interpreter for JoeyLib"); + break; + case CMD_SET_SCAN_START: + gAgi->logicScanStart[f->logic] = f->pc; + break; + case CMD_RESET_SCAN_START: + gAgi->logicScanStart[f->logic] = 0u; + break; + case CMD_REPOSITION_TO: + case CMD_REPOSITION_TO_V: { + AgiObjectT *o = object(a[0]); - testResult = evalOneTest(vm, op); - if (vm->haltReason != AGI_VM_HALT_NONE) { - return false; - } - if (notNext) { - testResult = (bool)!testResult; - notNext = false; - } - if (inOrGroup) { - orResult = (bool)(orResult || testResult); - } else { - andResult = (bool)(andResult && testResult); - } - } -} - - -// Unconditional relative jump. Offset is signed 16-bit; negative -// values allow backward jumps for loops. 16-bit arithmetic with -// separate positive / negative paths avoids dragging int32 into the -// 65816 build. -// Substitute AGI v2 message placeholders into an output buffer. -// Recognizes %v (decimal var value), %0 (decimal var with -// leading zero pad), %s (game string slot), %m (recursive -// message lookup in current logic). Unknown sequences are passed -// through verbatim. Always NUL-terminates within dstCap. -static void expandMessage(AgiVmT *vm, const char *src, char *dst, uint16_t dstCap) { - uint16_t out; - uint8_t pad; - uint8_t varIdx; - uint8_t value; - uint8_t tmp[4]; - int8_t digit; - char ch; - - out = 0u; - if (dstCap == 0u) { - return; - } - while (src != NULL && *src != '\0' && out + 1u < dstCap) { - ch = *src++; - if (ch != '%') { - dst[out++] = ch; - continue; - } - if (*src == '\0') { - dst[out++] = '%'; + if (o != NULL) { + // It stays where it was put for this cycle's step. + o->x = (op == CMD_REPOSITION_TO) ? a[1] : gAgi->vars[a[1]]; + o->y = (op == CMD_REPOSITION_TO) ? a[2] : gAgi->vars[a[2]]; + o->flags = (uint16_t)(o->flags | OBJ_SKIP_STEP); + objFixPosition(a[0]); + } break; } - { - char tag = *src++; - uint8_t arg = 0u; - uint8_t digitsRead = 0u; + case CMD_PRINT_AT: + printMessage(vmMessage(gAgi->currentLogic, a[0]), a[1], a[2], a[3]); + break; + case CMD_PRINT_AT_V: + printMessage(vmMessage(gAgi->currentLogic, gAgi->vars[a[0]]), a[1], a[2], a[3]); + break; + case CMD_DISCARD_VIEW_V: + saveScriptAdd(op, gAgi->vars[a[0]]); + break; + case CMD_CLEAR_TEXT_RECT: + textClearRect(a[0], a[1], a[2], a[3], a[4]); + break; + case CMD_SET_MENU: + textMenuAdd(vmMessage(gAgi->currentLogic, a[0])); + break; + case CMD_SET_MENU_ITEM: + textMenuAddItem(vmMessage(gAgi->currentLogic, a[0]), a[1]); + break; + case CMD_SUBMIT_MENU: + gAgi->menuSubmitted = true; + break; + case CMD_ENABLE_ITEM: + case CMD_DISABLE_ITEM: { + uint8_t i; - while (*src >= '0' && *src <= '9' && digitsRead < 3u) { - arg = (uint8_t)(arg * 10u + (uint8_t)(*src - '0')); - src++; - digitsRead++; - } - if (digitsRead == 0u) { - if (out + 2u < dstCap) { - dst[out++] = '%'; - dst[out++] = tag; + for (i = 0u; i < gAgi->menuItemCount; i++) { + if (gAgi->menuItems[i].controller == a[0]) { + gAgi->menuItems[i].enabled = (op == CMD_ENABLE_ITEM); } - continue; } - switch (tag) { - case 'v': case '0': - pad = (tag == '0') ? 1u : 0u; - varIdx = arg; - value = vm->vars[varIdx]; - digit = 0; - do { - tmp[digit++] = (uint8_t)(value % 10u); - value = (uint8_t)(value / 10u); - } while (value != 0u && digit < 3); - while (pad && digit < 3) { - tmp[digit++] = 0u; - } - while (digit > 0 && out + 1u < dstCap) { - digit--; - dst[out++] = (char)('0' + tmp[digit]); - } - break; - case 's': - if (arg < AGI_MAX_STRINGS) { - const char *s = vm->strings[arg]; - while (*s != '\0' && out + 1u < dstCap) { - dst[out++] = *s++; - } - } - break; - case 'm': { - const char *nested = fetchMessageOrEmpty(vm, vm->currentLogicId, arg); - char scratch[AGI_PRINT_MAX_LEN + 1u]; + break; + } + case CMD_MENU_INPUT: + if (gAgi->flags[FLAG_MENU] != 0u) { + gAgi->menuRequested = true; + } + break; + case CMD_MUL_N: + gAgi->vars[a[0]] = (uint8_t)(gAgi->vars[a[0]] * a[1]); + break; + case CMD_MUL_V: + gAgi->vars[a[0]] = (uint8_t)(gAgi->vars[a[0]] * gAgi->vars[a[1]]); + break; + case CMD_DIV_N: + if (a[1] != 0u) { + gAgi->vars[a[0]] = (uint8_t)(gAgi->vars[a[0]] / a[1]); + } + break; + case CMD_DIV_V: + if (gAgi->vars[a[1]] != 0u) { + gAgi->vars[a[0]] = (uint8_t)(gAgi->vars[a[0]] / gAgi->vars[a[1]]); + } + break; + case CMD_CLOSE_WINDOW: + textCloseWindow(); + 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. + break; + } +} - expandMessage(vm, nested, scratch, sizeof(scratch)); - { - const char *s = scratch; - while (*s != '\0' && out + 1u < dstCap) { - dst[out++] = *s++; - } - } - break; + +// Object commands (0x1E..0x5B). +static void cmdObject(VmFrameT *f, uint8_t op, const uint8_t *a) { + AgiObjectT *o; + + (void)f; + o = object(a[0]); + switch (op) { + case CMD_LOAD_VIEW: + case CMD_LOAD_VIEW_V: { + uint8_t id = (op == CMD_LOAD_VIEW) ? a[0] : gAgi->vars[a[0]]; + + (void)agiViewGet(id); + saveScriptAdd(CMD_LOAD_VIEW, id); + break; + } + case CMD_DISCARD_VIEW: + saveScriptAdd(op, a[0]); + break; + case CMD_ANIMATE_OBJ: + if (o != NULL) { + objAnimate(a[0]); + } + break; + case CMD_UNANIMATE_ALL: + objUnanimateAll(); + break; + case CMD_DRAW: + if (o != NULL && (o->flags & OBJ_DRAWN) == 0u && o->view != NULL) { + o->flags = (uint16_t)(o->flags | OBJ_ANIMATED); + objFixPosition(a[0]); + o->prevX = o->x; + o->prevY = o->y; + redrawObjects(a[0], (uint16_t)(OBJ_DRAWN | OBJ_UPDATE), 0u); + } + break; + case CMD_ERASE: + if (o != NULL && (o->flags & OBJ_DRAWN) != 0u) { + redrawObjects(a[0], 0u, OBJ_DRAWN); + } + break; + case CMD_POSITION: + case CMD_POSITION_V: + if (o != NULL) { + o->x = (op == CMD_POSITION) ? a[1] : gAgi->vars[a[1]]; + o->y = (op == CMD_POSITION) ? a[2] : gAgi->vars[a[2]]; + o->prevX = o->x; + o->prevY = o->y; + } + break; + case CMD_GET_POSN: + if (o != NULL) { + gAgi->vars[a[1]] = (uint8_t)o->x; + gAgi->vars[a[2]] = (uint8_t)o->y; + } + break; + case CMD_REPOSITION: + if (o != NULL) { + o->x = (int16_t)(o->x + (int8_t)gAgi->vars[a[1]]); + o->y = (int16_t)(o->y + (int8_t)gAgi->vars[a[2]]); + o->flags = (uint16_t)(o->flags | OBJ_SKIP_STEP); + objFixPosition(a[0]); + } + break; + case CMD_SET_VIEW: + case CMD_SET_VIEW_V: + if (o != NULL) { + (void)objSetView(a[0], (op == CMD_SET_VIEW) ? a[1] : gAgi->vars[a[1]]); + } + break; + case CMD_SET_LOOP: + case CMD_SET_LOOP_V: + if (o != NULL) { + objSetLoop(a[0], (op == CMD_SET_LOOP) ? a[1] : gAgi->vars[a[1]]); + } + break; + case CMD_FIX_LOOP: + if (o != NULL) { + o->flags = (uint16_t)(o->flags | OBJ_FIX_LOOP); + } + break; + case CMD_RELEASE_LOOP: + if (o != NULL) { + o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_FIX_LOOP); + } + break; + case CMD_SET_CEL: + case CMD_SET_CEL_V: + if (o != NULL) { + objSetCel(a[0], (op == CMD_SET_CEL) ? a[1] : gAgi->vars[a[1]]); + } + break; + case CMD_LAST_CEL: + if (o != NULL && o->view != NULL && o->loop < o->view->loopCount) { + gAgi->vars[a[1]] = (uint8_t)(o->view->loops[o->loop].celCount - 1u); + } + break; + case CMD_CURRENT_CEL: + gAgi->vars[a[1]] = (o != NULL) ? o->cel : 0u; + break; + case CMD_CURRENT_LOOP: + gAgi->vars[a[1]] = (o != NULL) ? o->loop : 0u; + break; + case CMD_CURRENT_VIEW: + gAgi->vars[a[1]] = (o != NULL) ? o->viewId : 0u; + break; + case CMD_NUMBER_OF_LOOPS: + gAgi->vars[a[1]] = (o != NULL && o->view != NULL) ? o->view->loopCount : 0u; + break; + case CMD_SET_PRIORITY: + case CMD_SET_PRIORITY_V: + if (o != NULL) { + o->priority = (op == CMD_SET_PRIORITY) ? a[1] : gAgi->vars[a[1]]; + o->flags = (uint16_t)(o->flags | OBJ_FIX_PRIORITY); + } + break; + case CMD_RELEASE_PRIORITY: + if (o != NULL) { + o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_FIX_PRIORITY); + } + break; + case CMD_GET_PRIORITY: + gAgi->vars[a[1]] = (o != NULL) ? o->priority : 0u; + break; + case CMD_STOP_UPDATE: + if (o != NULL && (o->flags & OBJ_UPDATE) != 0u) { + redrawObjects(a[0], 0u, OBJ_UPDATE); + } + break; + case CMD_START_UPDATE: + if (o != NULL && (o->flags & OBJ_UPDATE) == 0u) { + redrawObjects(a[0], OBJ_UPDATE, 0u); + } + break; + case CMD_FORCE_UPDATE: + if (o != NULL) { + redrawObjects(a[0], 0u, 0u); + } + break; + case CMD_IGNORE_HORIZON: + if (o != NULL) { + o->flags = (uint16_t)(o->flags | OBJ_IGNORE_HORIZON); + } + break; + case CMD_OBSERVE_HORIZON: + if (o != NULL) { + o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_IGNORE_HORIZON); + } + break; + case CMD_SET_HORIZON: + gAgi->horizon = a[0]; + break; + case CMD_OBJECT_ON_WATER: + case CMD_OBJECT_ON_LAND: + case CMD_OBJECT_ON_ANYTHING: + if (o != NULL) { + o->flags = (uint16_t)(o->flags & (uint16_t)~(OBJ_ON_WATER | OBJ_ON_LAND)); + if (op == CMD_OBJECT_ON_WATER) { + o->flags = (uint16_t)(o->flags | OBJ_ON_WATER); + } else if (op == CMD_OBJECT_ON_LAND) { + o->flags = (uint16_t)(o->flags | OBJ_ON_LAND); } - case 'g': - case 'w': - // %g (logic 0 message — global) and %w (parser word) - // — fall back to no-op so the script doesn't see - // garbage substitutions. - break; - default: - if (out + 2u < dstCap) { - dst[out++] = '%'; - dst[out++] = tag; - } - break; } + break; + case CMD_IGNORE_OBJS: + if (o != NULL) { + o->flags = (uint16_t)(o->flags | OBJ_IGNORE_OBJS); + } + break; + case CMD_OBSERVE_OBJS: + if (o != NULL) { + o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_IGNORE_OBJS); + } + break; + case CMD_DISTANCE: { + const AgiObjectT *p = object(a[1]); + uint16_t d; + + d = DISTANCE_FAR; + if (o != NULL && p != NULL && (o->flags & OBJ_DRAWN) != 0u && (p->flags & OBJ_DRAWN) != 0u) { + d = (uint16_t)(((o->x > p->x) ? o->x - p->x : p->x - o->x) + ((o->y > p->y) ? o->y - p->y : p->y - o->y)); + if (d > DISTANCE_MAX) { + d = DISTANCE_MAX; + } + } + gAgi->vars[a[2]] = (uint8_t)d; + break; } + case CMD_STOP_CYCLING: + if (o != NULL) { + o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_CYCLING); + } + break; + case CMD_START_CYCLING: + if (o != NULL) { + o->flags = (uint16_t)(o->flags | OBJ_CYCLING); + } + break; + case CMD_NORMAL_CYCLE: + case CMD_REVERSE_CYCLE: + if (o != NULL) { + o->cycleMode = (op == CMD_NORMAL_CYCLE) ? AGI_CYCLE_NORMAL : AGI_CYCLE_REVERSE; + o->flags = (uint16_t)(o->flags | OBJ_CYCLING); + } + break; + case CMD_END_OF_LOOP: + 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]; + gAgi->flags[a[1]] = 0u; + o->flags = (uint16_t)(o->flags | OBJ_CYCLING); + if ((o->flags & OBJ_DRAWN) != 0u) { + o->flags = (uint16_t)(o->flags | OBJ_SKIP_CYCLE); + } + } + break; + case CMD_CYCLE_TIME: + if (o != NULL) { + o->cycleTime = gAgi->vars[a[1]]; + o->cycleCount = o->cycleTime; + } + break; + case CMD_STOP_MOTION: + if (o != NULL) { + o->direction = AGI_DIR_NONE; + o->motion = AGI_MOTION_NORMAL; + if (a[0] == AGI_EGO) { + gAgi->vars[VAR_EGO_DIR] = AGI_DIR_NONE; + gAgi->playerControl = false; + } + } + break; + case CMD_START_MOTION: + if (o != NULL) { + o->motion = AGI_MOTION_NORMAL; + if (a[0] == AGI_EGO) { + gAgi->vars[VAR_EGO_DIR] = AGI_DIR_NONE; + gAgi->playerControl = true; + } + } + break; + case CMD_STEP_SIZE: + if (o != NULL) { + o->stepSize = gAgi->vars[a[1]]; + } + break; + case CMD_STEP_TIME: + if (o != NULL) { + o->stepTime = gAgi->vars[a[1]]; + o->stepCount = o->stepTime; + } + break; + case CMD_MOVE_OBJ: + if (o != NULL) { + objMoveTo(a[0], a[1], a[2], a[3], a[4]); + } + break; + case CMD_MOVE_OBJ_V: + if (o != NULL) { + objMoveTo(a[0], gAgi->vars[a[1]], gAgi->vars[a[2]], gAgi->vars[a[3]], a[4]); + } + 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; + } + break; + case CMD_WANDER: + if (o != NULL) { + o->motion = AGI_MOTION_WANDER; + o->randomSteps = 0u; + if (a[0] == AGI_EGO) { + gAgi->playerControl = false; + } + } + break; + case CMD_NORMAL_MOTION: + if (o != NULL) { + o->motion = AGI_MOTION_NORMAL; + } + break; + case CMD_SET_DIR: + if (o != NULL) { + o->direction = gAgi->vars[a[1]]; + } + break; + case CMD_GET_DIR: + gAgi->vars[a[1]] = (o != NULL) ? o->direction : 0u; + break; + case CMD_IGNORE_BLOCKS: + if (o != NULL) { + o->flags = (uint16_t)(o->flags | OBJ_IGNORE_BLOCKS); + } + break; + case CMD_OBSERVE_BLOCKS: + if (o != NULL) { + o->flags = (uint16_t)(o->flags & (uint16_t)~OBJ_IGNORE_BLOCKS); + } + break; + case CMD_BLOCK: + gAgi->blockActive = true; + gAgi->blockX1 = a[0]; + gAgi->blockY1 = a[1]; + gAgi->blockX2 = a[2]; + gAgi->blockY2 = a[3]; + break; + case CMD_UNBLOCK: + gAgi->blockActive = false; + break; + default: + break; } - dst[out] = '\0'; } -static bool executeGoto(AgiVmT *vm) { - uint16_t raw; - int16_t signedOffset; - uint16_t magnitude; +// Text, sound, inventory and input commands (0x5C..0x7B). +static void cmdText(VmFrameT *f, uint8_t op, const uint8_t *a) { + char text[MESSAGE_MAX]; - if (!readU16(vm, &raw)) { - return false; + (void)f; + switch (op) { + case CMD_GET: + gAgi->itemRoom[a[0]] = AGI_ITEM_CARRIED; + break; + case CMD_GET_V: + gAgi->itemRoom[gAgi->vars[a[0]]] = AGI_ITEM_CARRIED; + break; + case CMD_DROP: + gAgi->itemRoom[a[0]] = 0u; + break; + case CMD_PUT: + gAgi->itemRoom[a[0]] = gAgi->vars[a[1]]; + break; + case CMD_PUT_V: + gAgi->itemRoom[gAgi->vars[a[0]]] = gAgi->vars[a[1]]; + break; + case CMD_GET_ROOM_V: + gAgi->vars[a[1]] = gAgi->itemRoom[gAgi->vars[a[0]]]; + break; + case CMD_LOAD_SOUND: + saveScriptAdd(op, a[0]); + break; + case CMD_SOUND: + soundStart(a[0], a[1]); + break; + case CMD_STOP_SOUND: + soundStop(); + break; + case CMD_PRINT: + printMessage(vmMessage(gAgi->currentLogic, a[0]), -1, -1, 0u); + break; + case CMD_PRINT_V: + printMessage(vmMessage(gAgi->currentLogic, gAgi->vars[a[0]]), -1, -1, 0u); + break; + case CMD_DISPLAY: + textExpand(vmMessage(gAgi->currentLogic, a[2]), text, sizeof(text)); + textDisplay(a[0], a[1], text); + break; + case CMD_DISPLAY_V: + textExpand(vmMessage(gAgi->currentLogic, gAgi->vars[a[2]]), text, sizeof(text)); + textDisplay(gAgi->vars[a[0]], gAgi->vars[a[1]], text); + break; + case CMD_CLEAR_LINES: + textClearLines(a[0], a[1], a[2]); + break; + case CMD_TEXT_SCREEN: + textTextMode(); + break; + case CMD_GRAPHICS: + textGraphicsMode(); + break; + case CMD_SET_CURSOR_CHAR: + gAgi->cursorChar = vmMessage(gAgi->currentLogic, a[0])[0]; + break; + case CMD_SET_TEXT_ATTRIBUTE: + textSetAttribute(a[0], a[1]); + break; + case CMD_SHAKE_SCREEN: + gfxShakeScreen(a[0]); + break; + case CMD_CONFIGURE_SCREEN: + gAgi->picRow = a[0]; + gAgi->inputRow = a[1]; + gAgi->statusRow = a[2]; + break; + case CMD_STATUS_LINE_ON: + gAgi->statusOn = true; + textDrawStatus(); + break; + case CMD_STATUS_LINE_OFF: + gAgi->statusOn = false; + textClearLines(gAgi->statusRow, gAgi->statusRow, AGI_COLOR_BLACK); + break; + case CMD_SET_STRING: + if (a[0] < AGI_MAX_STRINGS) { + strncpy(gAgi->strings[a[0]], vmMessage(gAgi->currentLogic, a[1]), AGI_STRING_LEN); + gAgi->strings[a[0]][AGI_STRING_LEN] = '\0'; + } + break; + case CMD_GET_STRING: + if (a[0] < AGI_MAX_STRINGS) { + textExpand(vmMessage(gAgi->currentLogic, a[1]), text, sizeof(text)); + (void)inputGetString(gAgi->strings[a[0]], (uint8_t)((a[4] > AGI_STRING_LEN) ? AGI_STRING_LEN : a[4]), a[2], a[3], text); + } + break; + case CMD_WORD_TO_STRING: + if (a[0] < AGI_MAX_STRINGS && a[1] >= 1u && a[1] <= gAgi->wordCount) { + strcpy(gAgi->strings[a[0]], gAgi->wordText[a[1] - 1u]); + } + break; + case CMD_PARSE: + if (a[0] < AGI_MAX_STRINGS) { + inputParse(gAgi->strings[a[0]]); + } + break; + case CMD_GET_NUM: + textExpand(vmMessage(gAgi->currentLogic, a[0]), text, sizeof(text)); + if (!inputGetNumber(text, &gAgi->vars[a[1]])) { + gAgi->vars[a[1]] = 0u; + } + break; + case CMD_PREVENT_INPUT: + gAgi->inputOn = false; + textDrawInputLine(); + break; + case CMD_ACCEPT_INPUT: + gAgi->inputOn = true; + textDrawInputLine(); + break; + case CMD_SET_KEY: + if (gAgi->keyMapCount < AGI_MAX_KEYMAPS) { + gAgi->keyMaps[gAgi->keyMapCount].key = (uint16_t)(a[0] | ((uint16_t)a[1] << 8)); + gAgi->keyMaps[gAgi->keyMapCount].controller = a[2]; + gAgi->keyMapCount++; + } + break; + case CMD_ADD_TO_PIC: + gfxAddToPic(a[0], a[1], a[2], a[3], a[4], a[5], a[6]); + saveScriptAddArgs(op, a); + break; + case CMD_ADD_TO_PIC_V: { + uint8_t v[MAX_ARGS]; + uint8_t i; + + for (i = 0u; i < MAX_ARGS; i++) { + v[i] = gAgi->vars[a[i]]; + } + gfxAddToPic(v[0], v[1], v[2], v[3], v[4], v[5], v[6]); + saveScriptAddArgs(CMD_ADD_TO_PIC, v); + break; + } + default: + break; } - signedOffset = (int16_t)raw; - if (signedOffset >= 0) { - magnitude = (uint16_t)signedOffset; - if (magnitude > (uint16_t)(vm->codeLength - vm->pc)) { - vm->haltReason = AGI_VM_HALT_TRUNCATED; +} + + +// Variables, flags, rooms, logics and pictures (0x00..0x1D). +static void cmdVars(VmFrameT *f, uint8_t op, const uint8_t *a) { + uint8_t *v; + + v = gAgi->vars; + switch (op) { + case CMD_RETURN: + f->done = true; + break; + case CMD_INCREMENT: + if (v[a[0]] != UINT8_MAX) { + v[a[0]]++; + } + break; + case CMD_DECREMENT: + if (v[a[0]] != 0u) { + v[a[0]]--; + } + break; + case CMD_ASSIGNN: + v[a[0]] = a[1]; + break; + case CMD_ASSIGNV: + v[a[0]] = v[a[1]]; + break; + case CMD_ADDN: + v[a[0]] = (uint8_t)(v[a[0]] + a[1]); + break; + case CMD_ADDV: + v[a[0]] = (uint8_t)(v[a[0]] + v[a[1]]); + break; + case CMD_SUBN: + v[a[0]] = (uint8_t)(v[a[0]] - a[1]); + break; + case CMD_SUBV: + v[a[0]] = (uint8_t)(v[a[0]] - v[a[1]]); + break; + case CMD_LINDIRECTV: + v[v[a[0]]] = v[a[1]]; + break; + case CMD_RINDIRECT: + v[a[0]] = v[v[a[1]]]; + break; + case CMD_LINDIRECTN: + v[v[a[0]]] = a[1]; + break; + case CMD_SET: + gAgi->flags[a[0]] = 1u; + break; + case CMD_RESET: + gAgi->flags[a[0]] = 0u; + break; + case CMD_TOGGLE: + gAgi->flags[a[0]] = (uint8_t)(gAgi->flags[a[0]] ^ 1u); + break; + case CMD_SET_V: + gAgi->flags[v[a[0]]] = 1u; + break; + case CMD_RESET_V: + gAgi->flags[v[a[0]]] = 0u; + break; + case CMD_TOGGLE_V: + gAgi->flags[v[a[0]]] = (uint8_t)(gAgi->flags[v[a[0]]] ^ 1u); + break; + case CMD_NEW_ROOM: + agiNewRoom(a[0]); + break; + case CMD_NEW_ROOM_V: + agiNewRoom(v[a[0]]); + break; + case CMD_LOAD_LOGICS: + case CMD_LOAD_LOGICS_V: { + uint8_t id = (op == CMD_LOAD_LOGICS) ? a[0] : v[a[0]]; + + if (vmLoadLogic(id)) { + gAgi->logicLoaded[id] = true; + } + saveScriptAdd(CMD_LOAD_LOGICS, id); + break; + } + case CMD_CALL: + vmCallLogic(a[0]); + break; + case CMD_CALL_V: + vmCallLogic(v[a[0]]); + break; + case CMD_LOAD_PIC: + saveScriptAdd(op, v[a[0]]); + break; + case CMD_DRAW_PIC: + gfxDrawPic(v[a[0]], true); + saveScriptAdd(op, v[a[0]]); + break; + case CMD_SHOW_PIC: + gAgi->windowOpen = false; + gfxShowPic(); + break; + case CMD_DISCARD_PIC: + saveScriptAdd(op, v[a[0]]); + break; + case CMD_OVERLAY_PIC: + gfxDrawPic(v[a[0]], false); + saveScriptAdd(op, v[a[0]]); + break; + case CMD_SHOW_PRI_SCREEN: + gfxShowPriority(); + break; + default: + break; + } +} + + +// A logic resource's code length, its first (little-endian) word. +static uint16_t codeLength(const uint8_t *raw) { + return (uint16_t)(raw[0] | ((uint16_t)raw[1] << 8)); +} + + +// compare.strings: equal ignoring case, spaces and punctuation. +static bool compareStrings(const char *a, const char *b) { + char ca; + char cb; + + for (;;) { + while (*a != '\0' && !((*a >= 'a' && *a <= 'z') || (*a >= 'A' && *a <= 'Z') || (*a >= '0' && *a <= '9'))) { + a++; + } + while (*b != '\0' && !((*b >= 'a' && *b <= 'z') || (*b >= 'A' && *b <= 'Z') || (*b >= '0' && *b <= '9'))) { + b++; + } + if (*a == '\0' || *b == '\0') { + return *a == *b; + } + ca = (char)((*a >= 'A' && *a <= 'Z') ? *a - 'A' + 'a' : *a); + cb = (char)((*b >= 'A' && *b <= 'Z') ? *b - 'A' + 'a' : *b); + if (ca != cb) { return false; } - vm->pc = (uint16_t)(vm->pc + magnitude); - } else { - magnitude = (uint16_t)(-signedOffset); - if (magnitude > vm->pc) { - vm->haltReason = AGI_VM_HALT_TRUNCATED; - return false; - } - vm->pc = (uint16_t)(vm->pc - magnitude); + a++; + b++; } - return true; } -static bool executeIf(AgiVmT *vm) { - bool cond; - uint16_t skip; +// Decrypt a logic's message block in place: the key runs on from the +// first message's text to the end of the resource. +static void decryptMessages(uint8_t *raw, uint16_t length) { + uint16_t codeLen; + uint16_t sec; + uint8_t count; + uint16_t text; - cond = evalTests(vm); - if (vm->haltReason != AGI_VM_HALT_NONE) { - return false; - } - if (!readU16(vm, &skip)) { - return false; - } - if (!cond) { - if (skip > (uint16_t)(vm->codeLength - vm->pc)) { - vm->haltReason = AGI_VM_HALT_TRUNCATED; - return false; - } - vm->pc = (uint16_t)(vm->pc + skip); - } - return true; -} - - -static const char *fetchMessageOrEmpty(AgiVmT *vm, uint8_t logicId, uint8_t msgId) { - const char *msg; - - if (vm->callbacks.fetchMessage == NULL) { - return ""; - } - msg = vm->callbacks.fetchMessage(vm->callbacks.ctx, logicId, msgId); - return (msg != NULL) ? msg : ""; -} - - -// AGI print() default text-window width: ~30 columns. Sierra's -// interpreter word-wraps the message at this width before measuring -// the window. Without wrapping a long single-line message (e.g. -// KQ3's "Gwydion is a lonely lad...") overflows the screen edge. -#define AGI_PRINT_WRAP_COLS 30u - - -static void openPrintModal(AgiVmT *vm, const char *expanded) { - uint16_t i; - uint16_t out; - uint16_t cap; - uint16_t lineStart; // index in printModal.message where current line begins - uint16_t lastSpace; // index of last space char in current line (0 = none yet) - uint16_t col; // visible columns on current line - - cap = AGI_PRINT_MAX_LEN; - if (expanded == NULL) { - vm->printModal.message[0] = '\0'; - vm->printModal.length = 0u; - vm->printModal.active = true; - vm->haltReason = AGI_VM_HALT_PRINT_PENDING; + codeLen = codeLength(raw); + sec = (uint16_t)(LOGIC_HEADER_BYTES + codeLen); + if ((uint32_t)sec + MSG_HEADER_BYTES > length) { return; } + count = raw[sec]; + text = (uint16_t)(sec + MSG_HEADER_BYTES + (uint16_t)count * MSG_OFFSET_BYTES); + if (text < length) { + agiDecrypt(&raw[text], (uint16_t)(length - text)); + } +} - // Stream characters from expanded into printModal.message, breaking - // at AGI_PRINT_WRAP_COLS by replacing the last space on the line - // with '\n'. Preserves explicit '\n' from the source message. - out = 0u; - lineStart = 0u; - lastSpace = 0u; - col = 0u; - i = 0u; - while (expanded[i] != '\0' && out < cap - 1u) { - char c = expanded[i++]; - if (c == '\n') { - vm->printModal.message[out++] = '\n'; - lineStart = out; - lastSpace = 0u; - col = 0u; + +// An if's tests. Leaves pc after the closing 0xFF. +static bool evalCondition(VmFrameT *f) { + uint8_t b; + bool negate; + bool inOr; + bool orResult; + bool result; + bool skipping; + + negate = false; + inOr = false; + orResult = false; + result = true; + // Once the answer is known the remaining tests are only skipped. + skipping = false; + for (;;) { + b = readByte(f); + if (f->done) { + return false; + } + if (b == CODE_IF) { + return result; + } + if (b == CODE_NOT) { + negate = !negate; continue; } - vm->printModal.message[out++] = c; - if (c == ' ') { - lastSpace = (uint16_t)(out - 1u); - } - col++; - if (col >= AGI_PRINT_WRAP_COLS) { - if (lastSpace != 0u && lastSpace >= lineStart) { - // Replace the last space on this line with '\n' so - // the partial word that followed wraps to the next. - vm->printModal.message[lastSpace] = '\n'; - col = (uint16_t)(out - lastSpace - 1u); - lineStart = (uint16_t)(lastSpace + 1u); - lastSpace = 0u; + if (b == CODE_OR) { + if (!inOr) { + inOr = true; + orResult = false; } else { - // No space to break on: hard-wrap mid-token. - if (out < cap - 1u) { - vm->printModal.message[out++] = '\n'; + inOr = false; + if (!skipping && !orResult) { + result = false; + skipping = true; } - lineStart = out; - lastSpace = 0u; - col = 0u; } + continue; + } + if (skipping || (inOr && orResult)) { + skipTest(f, b); + negate = false; + continue; + } + if (evalTest(f, b) != negate) { + if (inOr) { + orResult = true; + } + } else if (!inOr) { + result = false; + skipping = true; + } + negate = false; + } +} + + +static bool evalTest(VmFrameT *f, uint8_t test) { + uint8_t a[MAX_ARGS]; + uint8_t i; + + if (test == TEST_SAID) { + return saidMatch(f); + } + if (test == 0u || test >= TEST_COUNT) { + f->done = true; + return false; + } + for (i = 0u; i < kTestArgs[test]; i++) { + a[i] = readByte(f); + } + switch (test) { + case TEST_EQUALN: return gAgi->vars[a[0]] == a[1]; + case TEST_EQUALV: return gAgi->vars[a[0]] == gAgi->vars[a[1]]; + case TEST_LESSN: return gAgi->vars[a[0]] < a[1]; + case TEST_LESSV: return gAgi->vars[a[0]] < gAgi->vars[a[1]]; + case TEST_GREATERN: return gAgi->vars[a[0]] > a[1]; + case TEST_GREATERV: return gAgi->vars[a[0]] > gAgi->vars[a[1]]; + case TEST_ISSET: return gAgi->flags[a[0]] != 0u; + case TEST_ISSET_V: return gAgi->flags[gAgi->vars[a[0]]] != 0u; + case TEST_HAS: return gAgi->itemRoom[a[0]] == AGI_ITEM_CARRIED; + 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_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); + } +} + + +// Run a logic frame until it returns or logic is aborted. +static void execute(VmFrameT *f) { + uint8_t op; + uint8_t a[MAX_ARGS]; + uint8_t i; + uint16_t skip; + + while (!f->done && !gAgi->abortLogic) { + op = readByte(f); + if (f->done) { + break; + } + if (op == CODE_IF) { + bool cond; + + cond = evalCondition(f); + skip = readWord(f); + if (!cond && !f->done) { + f->pc = (uint16_t)(f->pc + skip); + } + continue; + } + if (op == CODE_GOTO) { + skip = readWord(f); + f->pc = (uint16_t)(f->pc + skip); + 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)); + f->done = true; + break; + } + for (i = 0u; i < kActionArgs[op]; i++) { + a[i] = readByte(f); + } + if (op <= CMD_SHOW_PRI_SCREEN) { + cmdVars(f, op, a); + } else if (op <= CMD_UNBLOCK) { + cmdObject(f, op, a); + } else if (op <= CMD_ADD_TO_PIC_V) { + cmdText(f, op, a); + } else { + cmdMisc(f, op, a); } } - vm->printModal.message[out] = '\0'; - vm->printModal.length = out; - vm->printModal.active = true; - vm->haltReason = AGI_VM_HALT_PRINT_PENDING; } -// Restore the most recently pushed frame as the active context. -// Caller (OP_RETURN at depth > 0) has already confirmed callDepth > 0. -static bool popFrame(AgiVmT *vm) { - const AgiVmFrameT *f; - - vm->callDepth--; - f = &vm->callStack[vm->callDepth]; - vm->code = f->code; - vm->codeLength = f->codeLength; - vm->pc = f->pc; - vm->currentLogicId = f->logicId; - return true; +static AgiObjectT *object(uint8_t n) { + if (n >= gAgi->objectCount) { + return NULL; + } + return &gAgi->objects[n]; } -// Save the active context onto the call stack so OP_CALL can switch -// to a different logic. Returns false (and halts) if the stack is -// already full -- AGI v2 games keep call depth shallow so a small -// fixed-size stack is sufficient. -static bool pushFrame(AgiVmT *vm) { - AgiVmFrameT *f; +// posn, obj.in.box, center.posn and right.posn: a point of the object's +// baseline (its left end, both ends, middle, right end) in a box. +static bool posnTest(uint8_t test, const uint8_t *a) { + const AgiObjectT *o; + int16_t x1; + int16_t x2; - if (vm->callDepth >= AGI_VM_CALL_STACK_MAX) { - vm->haltReason = AGI_VM_HALT_STACK_OVER; + o = object(a[0]); + if (o == NULL) { return false; } - f = &vm->callStack[vm->callDepth]; - f->code = vm->code; - f->codeLength = vm->codeLength; - f->pc = vm->pc; - f->logicId = vm->currentLogicId; - vm->callDepth++; - return true; -} - - -static bool readByte(AgiVmT *vm, uint8_t *out) { - if (vm->pc >= vm->codeLength) { - vm->haltReason = AGI_VM_HALT_TRUNCATED; - return false; + x1 = o->x; + x2 = o->x; + if (test == TEST_OBJ_IN_BOX) { + x2 = (int16_t)(o->x + (int16_t)o->width - 1); + } else if (test == TEST_CENTER_POSN) { + x1 = (int16_t)(o->x + (int16_t)(o->width / 2u)); + x2 = x1; + } else if (test == TEST_RIGHT_POSN) { + x1 = (int16_t)(o->x + (int16_t)o->width - 1); + x2 = x1; } - *out = vm->code[vm->pc]; - vm->pc++; - return true; + return x1 >= (int16_t)a[1] && o->y >= (int16_t)a[2] && x2 <= (int16_t)a[3] && o->y <= (int16_t)a[4]; } -static bool readU16(AgiVmT *vm, uint16_t *out) { +// print and print.at: a message window that waits for Enter or Escape +// (or v21 half-seconds) unless f15 says to leave it up. +static void printMessage(const char *msg, int16_t row, int16_t col, uint8_t width) { + char text[MESSAGE_MAX]; + + textExpand(msg, text, sizeof(text)); + textPrint(text, row, col, width); + if (gAgi->flags[FLAG_WINDOW_STAYS] == 0u) { + (void)inputWaitPrintClose(gAgi->vars[VAR_WINDOW_TIMER]); + textCloseWindow(); + } +} + + +static uint8_t readByte(VmFrameT *f) { + if (f->pc >= f->length) { + f->done = true; + return 0u; + } + return f->code[f->pc++]; +} + + +static uint16_t readWord(VmFrameT *f) { uint8_t lo; uint8_t hi; - if (!readByte(vm, &lo)) { + lo = readByte(f); + hi = readByte(f); + return (uint16_t)(lo | ((uint16_t)hi << 8)); +} + + +// Change an object's drawn/updated flags with it off the screen, then +// draw everything again in order. +static void redrawObjects(uint8_t n, uint16_t setFlags, uint16_t clearFlags) { + AgiObjectT *o; + + o = &gAgi->objects[n]; + gfxEraseAll(); + o->flags = (uint16_t)((o->flags | setFlags) & (uint16_t)~clearFlags); + gfxDrawStatic(); + gfxPresentDirty(); +} + + +// said(): the typed words against a pattern (specification 3.8): word 1 +// matches any word, 9999 the rest of the line. A match sets f4 so later +// said() tests see the input as taken. An unknown word (v9) only matches +// those two, as in Sierra's interpreter. +static bool saidMatch(VmFrameT *f) { + uint8_t count; + uint8_t i; + uint16_t w; + uint8_t matched; + bool ok; + + count = readByte(f); + ok = gAgi->flags[FLAG_ENTERED] != 0u && gAgi->flags[FLAG_SAID_OK] == 0u; + matched = 0u; + for (i = 0u; i < count; i++) { + w = readWord(f); + if (!ok) { + continue; + } + if (w == AGI_WORD_REST) { + matched = gAgi->wordCount; + continue; + } + if (matched >= gAgi->wordCount || (w != AGI_WORD_ANY && w != gAgi->wordIds[matched])) { + ok = false; + continue; + } + matched++; + } + if (!ok || matched != gAgi->wordCount) { return false; } - if (!readByte(vm, &hi)) { - return false; - } - *out = (uint16_t)(lo | ((uint16_t)hi << 8)); + gAgi->flags[FLAG_SAID_OK] = 1u; return true; } -// Match the parsed player command against a said() word pattern. -// AGI_WORD_REST (9999) matches the rest of the line (including nothing); -// AGI_WORD_ANYWORD (1) matches exactly one arbitrary word; -// any other id matches that exact group id. -// An exact match requires every typed word to be consumed by the pattern. -static bool saidMatch(const AgiVmT *vm, const uint16_t *pat, uint8_t patCount) { - uint8_t pi; - uint8_t wi; +static void skipTest(VmFrameT *f, uint8_t test) { + uint8_t count; - pi = 0u; - wi = 0u; - while (pi < patCount) { - uint16_t pw; - - pw = pat[pi]; - if (pw == AGI_WORD_REST) { - return true; - } - if (wi >= vm->parsedWordCount) { - return false; - } - if (pw != AGI_WORD_ANYWORD && pw != vm->parsedWords[wi]) { - return false; - } - wi++; - pi++; - } - return wi == vm->parsedWordCount; -} - - -static void writeTextRow(AgiVmT *vm, uint8_t row, uint8_t col, const char *expanded) { - uint16_t i; - uint16_t off; - - if (row >= AGI_TEXT_ROWS || col >= AGI_TEXT_COLS) { + if (test == TEST_SAID) { + count = readByte(f); + f->pc = (uint16_t)(f->pc + (uint16_t)count * SAID_WORD_BYTES); return; } - if (expanded == NULL) { - return; + if (test < TEST_COUNT) { + f->pc = (uint16_t)(f->pc + kTestArgs[test]); } - // Pad text[0..col-1] with spaces. Otherwise the row starts with - // '\0' and agiTextRender skips the whole row -- display(r,c,m) - // with c>0 produces no visible text. - for (i = 0u; i < (uint16_t)col; i++) { - if (vm->textRows[row].text[i] == '\0') { - vm->textRows[row].text[i] = ' '; - } - } - off = col; - i = 0u; - while (expanded[i] != '\0' && off < AGI_TEXT_COLS) { - if (expanded[i] == '\n') { - // newline — clear rest of row, advance to next row col 0 - while (off < AGI_TEXT_COLS) { - vm->textRows[row].text[off++] = ' '; - } - vm->textRows[row].text[AGI_TEXT_COLS] = '\0'; - row++; - off = 0u; - if (row >= AGI_TEXT_ROWS) { - return; - } - } else { - vm->textRows[row].text[off++] = expanded[i]; - } - i++; - } - vm->textRows[row].text[AGI_TEXT_COLS] = '\0'; - vm->textRows[row].fg = vm->textFg; - vm->textRows[row].bg = vm->textBg; } // ----- Public API (alphabetical) ----- -void agiVmAckPrint(AgiVmT *vm) { - vm->printModal.active = false; - vm->printModal.length = 0u; - vm->printModal.message[0] = '\0'; - if (vm->haltReason == AGI_VM_HALT_PRINT_PENDING) { - vm->haltReason = AGI_VM_HALT_NONE; +// Run a logic from its scan start. One that was not loaded is loaded for +// the call and unloaded after. +void vmCallLogic(uint8_t logicId) { + VmFrameT frame; + uint8_t *raw; + uint8_t caller; + bool temporary; + + if (gAgi->abortLogic) { + return; + } + if (gCallDepth >= CALL_DEPTH_MAX) { + jlLogF("call: logic %u nested too deep", (unsigned)logicId); + return; + } + temporary = !gAgi->logicLoaded[logicId]; + if (!vmLoadLogic(logicId)) { + jlLogF("call: no logic %u", (unsigned)logicId); + return; + } + raw = gAgi->logicRaw[logicId]; + frame.code = raw + LOGIC_HEADER_BYTES; + frame.length = codeLength(raw); + frame.pc = gAgi->logicScanStart[logicId]; + frame.logic = logicId; + frame.done = false; + caller = gAgi->currentLogic; + gAgi->currentLogic = logicId; + gCallDepth++; + execute(&frame); + gCallDepth--; + gAgi->currentLogic = caller; + if (temporary) { + gAgi->logicScanStart[logicId] = 0u; } } -void agiVmDispatchKey(AgiVmT *vm, uint8_t key1, uint8_t key2) { - uint8_t i; +// new.room: forget every logic but logic 0 (and their scan starts) and +// give their memory back, as Sierra's interpreter does; the new room loads +// what it uses. Only called between cycles, with no logic running. +void vmDiscardLogics(void) { + uint16_t i; - for (i = 0u; i < (uint8_t)AGI_MAX_KEY_BINDINGS; i++) { - AgiKeyBindingT *b; - - b = &vm->keyBindings[i]; - if (b->active == 0u) { - continue; - } - if (b->key1 == key1 && b->key2 == key2) { - if (b->controller < AGI_MAX_CONTROLLERS) { - vm->controllerFired[b->controller] = 1u; - } - } + for (i = 1u; i < AGI_MAX_RESOURCES; i++) { + gAgi->logicLoaded[i] = false; + gAgi->logicScanStart[i] = 0u; + jlFree(gAgi->logicRaw[i]); + gAgi->logicRaw[i] = NULL; + gAgi->logicLength[i] = 0u; } } -void agiVmInit(AgiVmT *vm) { - uint8_t i; +// Make sure a logic's resource is in memory (they stay cached). +bool vmLoadLogic(uint8_t logicId) { + uint8_t *raw; + uint16_t length; + uint16_t codeLen; - memset(vm, 0, sizeof(*vm)); - vm->haltReason = AGI_VM_HALT_NONE; - vm->statusLineOn = false; - vm->statusLineRow = 0u; - vm->textFg = 15u; - vm->textBg = 0u; - vm->horizon = 36u; // canonical AGI default - vm->programControl = false; - vm->acceptInput = false; - vm->menuOpen = 0xFFu; - for (i = 0u; i < (uint8_t)AGI_TEXT_ROWS; i++) { - vm->textRows[i].text[0] = '\0'; - vm->textRows[i].fg = 15u; - vm->textRows[i].bg = 0u; + if (gAgi->logicRaw[logicId] != NULL) { + return true; } - agiObjResetAll(vm); -} - - -bool agiVmLoadLogic(AgiVmT *vm, const uint8_t *resourceBytes, uint16_t resourceLength) { - uint16_t bytecodeLen; - - if (resourceLength < AGI_LOGIC_HDR_BYTES) { + raw = agiResLoad(&gAgi->game, AGI_RES_LOGIC, logicId, &length); + if (raw == NULL) { return false; } - bytecodeLen = (uint16_t)(resourceBytes[0] | ((uint16_t)resourceBytes[1] << 8)); - if ((uint16_t)(AGI_LOGIC_HDR_BYTES + bytecodeLen) > resourceLength) { + codeLen = codeLength(raw); + if (length < LOGIC_HEADER_BYTES || (uint32_t)LOGIC_HEADER_BYTES + codeLen > length) { + jlFree(raw); return false; } - vm->code = resourceBytes + AGI_LOGIC_HDR_BYTES; - vm->codeLength = bytecodeLen; - vm->pc = 0u; - vm->haltReason = AGI_VM_HALT_NONE; - vm->lastUnknownOp = 0u; - vm->newRoomId = 0u; - vm->currentLogicId = 0u; - vm->callDepth = 0u; + decryptMessages(raw, length); + gAgi->logicRaw[logicId] = raw; + gAgi->logicLength[logicId] = length; return true; } -void agiVmResetToLogic(AgiVmT *vm, const uint8_t *bytecode, uint16_t bytecodeLength, uint8_t logicId) { - vm->code = bytecode; - vm->codeLength = bytecodeLength; - vm->pc = 0u; - vm->haltReason = AGI_VM_HALT_NONE; - vm->lastUnknownOp = 0u; - vm->newRoomId = 0u; - vm->currentLogicId = logicId; - vm->callDepth = 0u; -} +// A logic's message, decrypted; "" when it has none by that number. +const char *vmMessage(uint8_t logicId, uint8_t msgId) { + const uint8_t *raw; + uint16_t length; + uint16_t sec; + uint16_t off; - -AgiVmHaltE agiVmRun(AgiVmT *vm) { - uint8_t op; - VmDispatchE res; - - while (vm->haltReason == AGI_VM_HALT_NONE) { - if (!readByte(vm, &op)) { - // readByte sets haltReason = TRUNCATED on out-of-range. - return vm->haltReason; - } - res = vmStepCore(vm, op); - if (res == VM_DISPATCH_UNHANDLED) { - res = vmStepObject(vm, op); - } - if (res == VM_DISPATCH_UNHANDLED) { - res = vmStepText(vm, op); - } - if (res == VM_DISPATCH_UNHANDLED) { - res = vmStepMisc(vm, op); - } - if (res == VM_DISPATCH_RETURN) { - return vm->haltReason; - } + if (!vmLoadLogic(logicId)) { + return ""; } - return vm->haltReason; + raw = gAgi->logicRaw[logicId]; + length = gAgi->logicLength[logicId]; + sec = (uint16_t)(LOGIC_HEADER_BYTES + codeLength(raw)); + if (msgId == 0u || (uint32_t)sec + MSG_HEADER_BYTES > length || msgId > raw[sec]) { + return ""; + } + off = (uint16_t)(sec + MSG_HEADER_BYTES + (uint16_t)(msgId - 1u) * MSG_OFFSET_BYTES); + if ((uint32_t)off + 1u >= length) { + return ""; + } + off = (uint16_t)(raw[off] | ((uint16_t)raw[off + 1u] << 8)); + if (off == 0u || (uint32_t)sec + MSG_OFFSET_BASE + off >= length) { + return ""; + } + return (const char *)&raw[sec + MSG_OFFSET_BASE + off]; } -// Opcode dispatch is partitioned across four __attribute__((noinline)) -// handlers so the llvm-mos w65816 register allocator is never asked to -// colour the whole 1200-line switch at once (it ran out of registers when -// it was a single function). Each handler returns VM_DISPATCH_CONTINUE -// (advance to the next opcode), VM_DISPATCH_RETURN (stop; agiVmRun returns -// vm->haltReason), or VM_DISPATCH_UNHANDLED (opcode is owned by a later -// group). vmStepMisc owns the default fallback and never returns UNHANDLED. -// Behaviour is byte-for-byte identical to the original monolithic switch. -// -// Group 1: RETURN, arithmetic/flags (0x00-0x11), IF/GOTO, new.room, -// load.logic, call, and the picture-cache opcodes (0x12-0x1C). -static VmDispatchE __attribute__((noinline)) vmStepCore(AgiVmT *vm, uint8_t op) { - uint8_t a; - uint8_t b; - - switch (op) { - case OP_RETURN: - if (vm->callDepth > 0u) { - popFrame(vm); - } else { - vm->haltReason = AGI_VM_HALT_RETURN; - } - break; - - case OP_INCREMENT: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - // AGI spec: increment clamps at 255. - if (vm->vars[a] != 0xFFu) { - vm->vars[a] = (uint8_t)(vm->vars[a] + 1u); - } - break; - - case OP_DECREMENT: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - // AGI spec: decrement clamps at 0 (does NOT wrap). - // Sparkle counters like v221..v224 stay at 0 once - // assigned 0, which is how the wizard scene suppresses - // the logo-sparkle loop until sound 22 ends. - if (vm->vars[a] != 0u) { - vm->vars[a] = (uint8_t)(vm->vars[a] - 1u); - } - break; - - case OP_ASSIGNN: - if (!readByte(vm, &a) || !readByte(vm, &b)) { - return VM_DISPATCH_RETURN; - } - vm->vars[a] = b; - break; - - case OP_ASSIGNV: - if (!readByte(vm, &a) || !readByte(vm, &b)) { - return VM_DISPATCH_RETURN; - } - vm->vars[a] = vm->vars[b]; - break; - - case OP_ADDN: - if (!readByte(vm, &a) || !readByte(vm, &b)) { - return VM_DISPATCH_RETURN; - } - vm->vars[a] = (uint8_t)(vm->vars[a] + b); - break; - - case OP_ADDV: - if (!readByte(vm, &a) || !readByte(vm, &b)) { - return VM_DISPATCH_RETURN; - } - vm->vars[a] = (uint8_t)(vm->vars[a] + vm->vars[b]); - break; - - case OP_SUBN: - if (!readByte(vm, &a) || !readByte(vm, &b)) { - return VM_DISPATCH_RETURN; - } - vm->vars[a] = (uint8_t)(vm->vars[a] - b); - break; - - case OP_SUBV: - if (!readByte(vm, &a) || !readByte(vm, &b)) { - return VM_DISPATCH_RETURN; - } - vm->vars[a] = (uint8_t)(vm->vars[a] - vm->vars[b]); - break; - - case OP_LINDIRECTV: - // var[a] = var[var[b]] - if (!readByte(vm, &a) || !readByte(vm, &b)) { - return VM_DISPATCH_RETURN; - } - vm->vars[a] = vm->vars[vm->vars[b]]; - break; - - case OP_RINDIRECT: - // var[var[a]] = var[b] - if (!readByte(vm, &a) || !readByte(vm, &b)) { - return VM_DISPATCH_RETURN; - } - vm->vars[vm->vars[a]] = vm->vars[b]; - break; - - case OP_LINDIRECTN: - // var[var[a]] = b (immediate) - if (!readByte(vm, &a) || !readByte(vm, &b)) { - return VM_DISPATCH_RETURN; - } - vm->vars[vm->vars[a]] = b; - break; - - case OP_SET: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - vm->flags[a] = 1u; - break; - - case OP_RESET: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - vm->flags[a] = 0u; - break; - - case OP_TOGGLE: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - vm->flags[a] = (uint8_t)(vm->flags[a] ? 0u : 1u); - break; - - case OP_SETV: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - vm->flags[vm->vars[a]] = 1u; - break; - - case OP_RESETV: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - vm->flags[vm->vars[a]] = 0u; - break; - - case OP_TOGGLEV: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - vm->flags[vm->vars[a]] = (uint8_t)(vm->flags[vm->vars[a]] ? 0u : 1u); - break; - - case OP_IF: - if (!executeIf(vm)) { - return VM_DISPATCH_RETURN; - } - break; - - case OP_GOTO: - if (!executeGoto(vm)) { - return VM_DISPATCH_RETURN; - } - break; - - case OP_NEW_ROOM: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - vm->newRoomId = a; - vm->haltReason = AGI_VM_HALT_NEW_ROOM; - jlLogF("OP_NEW_ROOM -> %u (v11=%u v12=%u)", - (unsigned)a, (unsigned)vm->vars[11], (unsigned)vm->vars[12]); - jlLogFlush(); - break; - - case OP_NEW_ROOM_V: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - vm->newRoomId = vm->vars[a]; - vm->haltReason = AGI_VM_HALT_NEW_ROOM; - jlLogF("OP_NEW_ROOM_V -> %u (v11=%u v12=%u)", - (unsigned)vm->newRoomId, (unsigned)vm->vars[11], (unsigned)vm->vars[12]); - jlLogFlush(); - break; - - case OP_LOAD_LOGIC: - case OP_LOAD_LOGIC_V: - // load.logic just primes the host's cache; the actual - // bytecode swap happens at OP_CALL time. We touch the - // callback so callers that prefetch can react. - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - if (vm->callbacks.fetchLogic != NULL) { - uint8_t logicId; - uint16_t ignore; - - logicId = (op == OP_LOAD_LOGIC) ? a : vm->vars[a]; - ignore = 0u; - (void)vm->callbacks.fetchLogic(vm->callbacks.ctx, logicId, &ignore); - } - break; - - case OP_CALL: - case OP_CALL_V: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - { - uint8_t logicId; - - logicId = (op == OP_CALL) ? a : vm->vars[a]; - if (!pushFrame(vm)) { - return VM_DISPATCH_RETURN; - } - if (!enterLogic(vm, logicId)) { - // Roll back the pushed frame so RETURN doesn't - // pop garbage state. - vm->callDepth--; - return VM_DISPATCH_RETURN; - } - } - break; - - case OP_LOAD_PIC: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - if (vm->callbacks.loadPic != NULL) { - vm->callbacks.loadPic(vm->callbacks.ctx, vm->vars[a]); - } - break; - - case OP_DRAW_PIC: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - if (vm->callbacks.drawPic != NULL) { - vm->callbacks.drawPic(vm->callbacks.ctx, vm->vars[a]); - } - break; - - case OP_SHOW_PIC: - if (vm->callbacks.showPic != NULL) { - vm->callbacks.showPic(vm->callbacks.ctx); - } - break; - - case OP_DISCARD_PIC: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - if (vm->callbacks.discardPic != NULL) { - vm->callbacks.discardPic(vm->callbacks.ctx, vm->vars[a]); - } - break; - - case OP_OVERLAY_PIC: - if (!readByte(vm, &a)) { - return VM_DISPATCH_RETURN; - } - if (vm->callbacks.overlayPic != NULL) { - vm->callbacks.overlayPic(vm->callbacks.ctx, vm->vars[a]); - } - break; - - default: - return VM_DISPATCH_UNHANDLED; - } - return VM_DISPATCH_CONTINUE; -} - - -// Group 2: object table (set.view/loop/cel/priority, draw/erase, get.posn, -// etc.), the lumped view-cache and reposition aliases (0x94/0x99), -// object-collision/block opcodes, animation cycling, and motion (0x1E-0x5B). -static VmDispatchE __attribute__((noinline)) vmStepObject(AgiVmT *vm, uint8_t op) { - uint8_t a; - uint8_t b; - uint8_t c; - uint8_t d; - uint8_t e; - - switch (op) { - // ----- view cache hints (no-op; host loads on demand) ----- - case OP_LOAD_VIEW: - case OP_LOAD_VIEW_V: - case OP_DISCARD_VIEW: - case OP_DISCARD_VIEW_V: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - // ----- object table ----- - case OP_ANIMATE_OBJ: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - jlLogF("OP_ANIMATE_OBJ %u", (unsigned)a); - if (a < AGI_MAX_OBJECTS) { - agiObjReset(&vm->objects[a]); - vm->objects[a].flags |= AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_UPDATING | AGI_OBJ_FLAG_CYCLING; - } - break; - - case OP_UNANIMATE_ALL: - jlLog("OP_UNANIMATE_ALL"); - agiObjResetAll(vm); - break; - - case OP_DRAW: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - jlLogF("OP_DRAW %u", (unsigned)a); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].flags |= AGI_OBJ_FLAG_DRAWN; - } - break; - - case OP_ERASE: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - jlLogF("OP_ERASE %u", (unsigned)a); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_DRAWN; - } - break; - - case OP_POSITION: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - jlLogF("OP_POSITION %u (%u,%u)", (unsigned)a, (unsigned)b, (unsigned)c); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].x = (int16_t)b; - vm->objects[a].y = (int16_t)c; - } - break; - - case OP_POSITION_V: - case OP_REPOSITION_TO_V: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].x = (int16_t)vm->vars[b]; - vm->objects[a].y = (int16_t)vm->vars[c]; - jlLogF("OP_POSITION_V/RTO_V %u <- v%u/v%u = (%d,%d)", - (unsigned)a, (unsigned)b, (unsigned)c, - (int)vm->objects[a].x, (int)vm->objects[a].y); - } - break; - - case OP_GET_POSN: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - if (a < AGI_MAX_OBJECTS) { - vm->vars[b] = (uint8_t)vm->objects[a].x; - vm->vars[c] = (uint8_t)vm->objects[a].y; - } - break; - - case OP_REPOSITION: - // reposition(N, vdx, vdy) — apply signed deltas (vars hold int8_t-style) - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - if (a < AGI_MAX_OBJECTS) { - int8_t dx = (int8_t)vm->vars[b]; - int8_t dy = (int8_t)vm->vars[c]; - vm->objects[a].x = (int16_t)(vm->objects[a].x + dx); - vm->objects[a].y = (int16_t)(vm->objects[a].y + dy); - } - break; - - case OP_REPOSITION_TO: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - jlLogF("OP_REPOSITION_TO %u (%u,%u)", (unsigned)a, (unsigned)b, (unsigned)c); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].x = (int16_t)b; - vm->objects[a].y = (int16_t)c; - } - break; - - case OP_SET_VIEW: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - jlLogF("OP_SET_VIEW obj=%u view=%u", (unsigned)a, (unsigned)b); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].viewId = b; - vm->objects[a].cel = 0u; - } - break; - - case OP_SET_VIEW_V: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - jlLogF("OP_SET_VIEW_V obj=%u view=v%u (=%u)", - (unsigned)a, (unsigned)b, (unsigned)vm->vars[b]); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].viewId = vm->vars[b]; - vm->objects[a].cel = 0u; - } - break; - - case OP_SET_LOOP: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - jlLogF("OP_SET_LOOP obj=%u loop=%u", (unsigned)a, (unsigned)b); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].loop = b; - vm->objects[a].cel = 0u; - } - break; - - case OP_SET_LOOP_V: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - jlLogF("OP_SET_LOOP_V obj=%u loop=v%u (=%u)", - (unsigned)a, (unsigned)b, (unsigned)vm->vars[b]); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].loop = vm->vars[b]; - vm->objects[a].cel = 0u; - } - break; - - case OP_FIX_LOOP: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_LOOP_FIXED; } - break; - - case OP_RELEASE_LOOP: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_LOOP_FIXED; } - break; - - case OP_SET_CEL: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].cel = b; vm->objects[a].cycleTick = 0u; } - break; - - case OP_SET_CEL_V: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].cel = vm->vars[b]; vm->objects[a].cycleTick = 0u; } - break; - - case OP_LAST_CEL: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS && vm->callbacks.viewCelCount != NULL) { - uint8_t c = vm->callbacks.viewCelCount(vm->callbacks.ctx, - vm->objects[a].viewId, - vm->objects[a].loop); - vm->vars[b] = (c > 0u) ? (uint8_t)(c - 1u) : 0u; - } else { - vm->vars[b] = 0u; - } - break; - - case OP_CURRENT_CEL: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].cel : 0u; - break; - - case OP_CURRENT_LOOP: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].loop : 0u; - break; - - case OP_CURRENT_VIEW: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].viewId : 0u; - break; - - case OP_NUMBER_OF_LOOPS: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS && vm->callbacks.viewLoopCount != NULL) { - uint8_t c = vm->callbacks.viewLoopCount(vm->callbacks.ctx, - vm->objects[a].viewId); - vm->vars[b] = (c > 0u) ? c : 1u; - } else { - vm->vars[b] = 1u; - } - break; - - case OP_SET_PRIORITY: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].priority = b; - vm->objects[a].flags |= AGI_OBJ_FLAG_PRI_FIXED; - } - break; - - case OP_SET_PRIORITY_V: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].priority = vm->vars[b]; - vm->objects[a].flags |= AGI_OBJ_FLAG_PRI_FIXED; - } - break; - - case OP_RELEASE_PRIORITY: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_PRI_FIXED; } - break; - - case OP_GET_PRIORITY: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].priority : 0u; - break; - - case OP_STOP_UPDATE: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_UPDATING; } - break; - - case OP_START_UPDATE: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_UPDATING; } - break; - - case OP_FORCE_UPDATE: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - // Force-redraw hint; we always redraw, so no-op apart from arg consume. - break; - - case OP_IGNORE_HORIZON: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_HORIZON_IGN; } - break; - - case OP_OBSERVE_HORIZON: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_HORIZON_IGN; } - break; - - case OP_SET_HORIZON: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - vm->horizon = a; - break; - - case OP_OBJECT_ON_WATER: - case OP_OBJECT_ON_LAND: - case OP_OBJECT_ON_ANYTHING: - case OP_IGNORE_BLOCKS: - case OP_OBSERVE_BLOCKS: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_IGNORE_OBJS: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_OBJS_IGN; } - break; - - case OP_OBSERVE_OBJS: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_OBJS_IGN; } - break; - - case OP_DISTANCE: { - int16_t dx; - int16_t dy; - - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - if (a < AGI_MAX_OBJECTS && b < AGI_MAX_OBJECTS) { - dx = (int16_t)(vm->objects[a].x - vm->objects[b].x); - dy = (int16_t)(vm->objects[a].y - vm->objects[b].y); - if (dx < 0) { dx = (int16_t)(-dx); } - if (dy < 0) { dy = (int16_t)(-dy); } - vm->vars[c] = (uint8_t)((dx + dy > 255) ? 255 : (dx + dy)); - } else { - vm->vars[c] = 255u; - } - break; - } - - case OP_BLOCK: - // 4 args: (x1, y1, x2, y2) - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d)) { - return VM_DISPATCH_RETURN; - } - break; - - case OP_UNBLOCK: - break; - - // ----- animation cycling ----- - case OP_STOP_CYCLING: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_CYCLING; } - break; - - case OP_START_CYCLING: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; } - break; - - case OP_NORMAL_CYCLE: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].cycleMode = AGI_CYCLE_NORMAL; - vm->objects[a].endOfLoopFlag = 0u; - vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; - } - break; - - case OP_END_OF_LOOP: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].cycleMode = AGI_CYCLE_END_LOOP; - vm->objects[a].endOfLoopFlag = b; - vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; - vm->flags[b] = 0u; - } - break; - - case OP_REVERSE_CYCLE: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].cycleMode = AGI_CYCLE_REVERSE; - vm->objects[a].endOfLoopFlag = 0u; - vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; - } - break; - - case OP_REVERSE_LOOP: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].cycleMode = AGI_CYCLE_REV_LOOP; - vm->objects[a].endOfLoopFlag = b; - vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; - vm->flags[b] = 0u; - } - break; - - case OP_CYCLE_TIME: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].cycleTime = vm->vars[b]; - if (vm->objects[a].cycleTime == 0u) { vm->objects[a].cycleTime = 1u; } - vm->objects[a].cycleTick = 0u; - } - break; - - // ----- motion ----- - case OP_STOP_MOTION: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].direction = 0u; } - break; - - case OP_START_MOTION: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - if (a == 0u) { - vm->objects[0].direction = vm->vars[ENG_VAR_EGO_DIR]; - } else { - // Re-allow motion; direction stays whatever it was. - } - } - break; - - case OP_STEP_SIZE: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].stepSize = vm->vars[b]; - if (vm->objects[a].stepSize == 0u) { vm->objects[a].stepSize = 1u; } - } - break; - - case OP_STEP_TIME: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].stepTime = vm->vars[b]; - if (vm->objects[a].stepTime == 0u) { vm->objects[a].stepTime = 1u; } - vm->objects[a].stepTick = 0u; - } - break; - - case OP_MOVE_OBJ: - // n, x, y, vstep, flag - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) { - return VM_DISPATCH_RETURN; - } - jlLogF("OP_MOVE_OBJ %u -> (%u,%u) step=%u doneFlag=%u", - (unsigned)a, (unsigned)b, (unsigned)c, (unsigned)d, (unsigned)e); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].targetX = (int16_t)b; - vm->objects[a].targetY = (int16_t)c; - vm->objects[a].moveStepBackup = vm->objects[a].stepSize; - vm->objects[a].stepSize = (d == 0u) ? vm->objects[a].stepSize : d; - vm->objects[a].moveDoneFlag = e; - vm->objects[a].motionType = AGI_MOTION_MOVE_OBJ; - vm->flags[e] = 0u; - } - break; - - case OP_MOVE_OBJ_V: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) { - return VM_DISPATCH_RETURN; - } - jlLogF("OP_MOVE_OBJ_V %u -> v%u/v%u=(%u,%u) vstep=v%u=%u doneFlag=%u", - (unsigned)a, (unsigned)b, (unsigned)c, - (unsigned)vm->vars[b], (unsigned)vm->vars[c], - (unsigned)d, (unsigned)vm->vars[d], (unsigned)e); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].targetX = (int16_t)vm->vars[b]; - vm->objects[a].targetY = (int16_t)vm->vars[c]; - vm->objects[a].moveStepBackup = vm->objects[a].stepSize; - vm->objects[a].stepSize = (vm->vars[d] == 0u) ? vm->objects[a].stepSize : vm->vars[d]; - vm->objects[a].moveDoneFlag = e; - vm->objects[a].motionType = AGI_MOTION_MOVE_OBJ; - vm->flags[e] = 0u; - } - break; - - case OP_FOLLOW_EGO: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].followStep = b; - vm->objects[a].followDoneFlag = c; - vm->objects[a].motionType = AGI_MOTION_FOLLOW_EGO; - } - break; - - case OP_WANDER: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].motionType = AGI_MOTION_WANDER; - vm->objects[a].wanderTick = 0u; - } - break; - - case OP_NORMAL_MOTION: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_OBJECTS) { vm->objects[a].motionType = AGI_MOTION_NORMAL; } - break; - - case OP_SET_DIR: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - jlLogF("OP_SET_DIR obj=%u dir=v%u (=%u)", - (unsigned)a, (unsigned)b, (unsigned)vm->vars[b]); - if (a < AGI_MAX_OBJECTS) { - vm->objects[a].direction = vm->vars[b]; - if (a == 0u) { vm->vars[ENG_VAR_EGO_DIR] = vm->objects[0].direction; } - } - break; - - case OP_GET_DIR: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].direction : 0u; - break; - - default: - return VM_DISPATCH_UNHANDLED; - } - return VM_DISPATCH_CONTINUE; -} - - -// Group 3: inventory state (0x5C-0x61), sound (0x62-0x64), and the text -// opcodes (0x65-0x76, plus the 0x9A clear.text.rect alias lumped here). -static VmDispatchE __attribute__((noinline)) vmStepText(AgiVmT *vm, uint8_t op) { - uint8_t a; - uint8_t b; - uint8_t c; - uint8_t d; - uint8_t e; - - switch (op) { - // ----- inventory (state only; reads OBJECT data via host later) ----- - case OP_GET: - case OP_DROP: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_GET_V: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_PUT: - case OP_PUT_V: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - break; - - case OP_GET_ROOM_V: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - vm->vars[b] = 0u; - break; - - // ----- sound (no-op; complete callback fires immediately) ----- - case OP_LOAD_SOUND: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_SOUND: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - jlLogF("OP_SOUND id=%u flag=%u (prev playing=%u prevFlag=%u)", - (unsigned)a, (unsigned)b, - (unsigned)vm->soundPlaying, (unsigned)vm->soundDoneFlag); - jlLogFlush(); - // AGI v2 sound(N, F): both args are LITERAL. - // (No _V variant exists for sound; first arg is - // always the sound resource id, second is the - // completion flag id.) Don't index through vars. - // - // If a previous sound is still armed, fire its - // done flag now (the script's about to wait on the - // new sound; the old one's completion is the new - // sound's start, not whenever playback ended). - if (vm->soundPlaying) { - if (vm->soundDoneFlag != 0u) { - vm->flags[vm->soundDoneFlag] = 1u; - } - vm->flags[9] = 1u; - vm->soundPlaying = false; - } - if (vm->callbacks.playSound != NULL && vm->callbacks.isPlayingSound != NULL) { - vm->soundDoneFlag = b; - vm->flags[9] = 0u; - // ScummVM startSound does setFlagOrVar(_endflag, - // false) here -- without it the script's - // `while (!flag[b]) wait` loop sees a stale 1 - // from a previous sound() and skips waiting. - vm->flags[b] = 0u; - vm->callbacks.playSound(vm->callbacks.ctx, a); - vm->soundPlaying = true; - } else { - // Legacy fire-immediately when the host can't - // report playback state. Keeps non-AGI uses of - // this VM (and any test harness without audio) - // from blocking forever on a wait-for-sound flag. - vm->flags[b] = 1u; - vm->flags[9] = 1u; - } - break; - - case OP_STOP_SOUND: - jlLogF("OP_STOP_SOUND (playing=%u flag=%u)", - (unsigned)vm->soundPlaying, - (unsigned)vm->soundDoneFlag); - jlLogFlush(); - if (vm->callbacks.stopSound != NULL) { - vm->callbacks.stopSound(vm->callbacks.ctx); - } - if (vm->soundPlaying) { - if (vm->soundDoneFlag != 0u) { - vm->flags[vm->soundDoneFlag] = 1u; - } - vm->flags[9] = 1u; - vm->soundPlaying = false; - } - break; - - // ----- text ----- - case OP_PRINT: { - char expanded[AGI_PRINT_MAX_LEN + 1u]; - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, a), expanded, sizeof(expanded)); - openPrintModal(vm, expanded); - return VM_DISPATCH_RETURN; - } - - case OP_PRINT_V: { - char expanded[AGI_PRINT_MAX_LEN + 1u]; - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, vm->vars[a]), expanded, sizeof(expanded)); - openPrintModal(vm, expanded); - return VM_DISPATCH_RETURN; - } - - case OP_DISPLAY: { - char expanded[AGI_PRINT_MAX_LEN + 1u]; - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, c), expanded, sizeof(expanded)); - jlLogF("OP_DISPLAY row=%u col=%u msg=%u text=\"%s\"", - (unsigned)a, (unsigned)b, (unsigned)c, expanded); - writeTextRow(vm, a, b, expanded); - break; - } - - case OP_DISPLAY_V: { - char expanded[AGI_PRINT_MAX_LEN + 1u]; - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, vm->vars[c]), expanded, sizeof(expanded)); - writeTextRow(vm, vm->vars[a], vm->vars[b], expanded); - break; - } - - case OP_CLEAR_LINES: { - uint8_t r; - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - jlLogF("OP_CLEAR_LINES rows=%u..%u bg=%u", - (unsigned)a, (unsigned)b, (unsigned)c); - if (a >= AGI_TEXT_ROWS) a = AGI_TEXT_ROWS - 1u; - if (b >= AGI_TEXT_ROWS) b = AGI_TEXT_ROWS - 1u; - for (r = a; r <= b; r++) { - vm->textRows[r].text[0] = '\0'; - vm->textRows[r].bg = c; - } - break; - } - - case OP_CLEAR_TEXT_RECT: { - uint8_t r; - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) { - return VM_DISPATCH_RETURN; - } - if (c >= AGI_TEXT_ROWS) c = AGI_TEXT_ROWS - 1u; - for (r = a; r <= c; r++) { - vm->textRows[r].text[0] = '\0'; - vm->textRows[r].bg = e; - } - break; - } - - case OP_TEXT_SCREEN: - case OP_GRAPHICS: - break; - - case OP_SET_CURSOR_CHAR: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_SET_TEXT_ATTRIBUTE: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - vm->textFg = a; - vm->textBg = b; - break; - - case OP_SHAKE_SCREEN: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_CONFIGURE_SCREEN: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - break; - - case OP_STATUS_LINE_ON: - vm->statusLineOn = true; - break; - - case OP_STATUS_LINE_OFF: - vm->statusLineOn = false; - break; - - case OP_SET_STRING: { - char expanded[AGI_STRING_LEN + 1u]; - uint8_t i; - - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (a < AGI_MAX_STRINGS) { - expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, b), expanded, sizeof(expanded)); - for (i = 0u; i < (uint8_t)AGI_STRING_LEN; i++) { - vm->strings[a][i] = expanded[i]; - if (expanded[i] == '\0') break; - } - vm->strings[a][AGI_STRING_LEN] = '\0'; - } - break; - } - - case OP_GET_STRING: - // get.string(s, msgId, row, col, max) — parser input; - // no parser yet, just consume args. - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) { - return VM_DISPATCH_RETURN; - } - break; - - case OP_WORD_TO_STRING: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - break; - - case OP_PARSE: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_GET_NUM: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - vm->vars[b] = 0u; - break; - - default: - return VM_DISPATCH_UNHANDLED; - } - return VM_DISPATCH_CONTINUE; -} - - -// Group 4: input (0x77-0x79), add.to.pic (0x7A-0x7B), the misc/system -// opcodes, print.at aliases, menus, and mul/div (0x7C-0xAA). Owns the -// default fallback (kActionArgBytes stub-consume / unknown-op halt), so it -// never returns VM_DISPATCH_UNHANDLED. -static VmDispatchE __attribute__((noinline)) vmStepMisc(AgiVmT *vm, uint8_t op) { - uint8_t a; - uint8_t b; - uint8_t c; - uint8_t d; - uint8_t e; - uint8_t f; - uint8_t g; - - switch (op) { - // ----- input ----- - case OP_PREVENT_INPUT: - vm->acceptInput = false; - break; - - case OP_ACCEPT_INPUT: - vm->acceptInput = true; - break; - - case OP_SET_KEY: { - uint8_t i; - - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - for (i = 0u; i < (uint8_t)AGI_MAX_KEY_BINDINGS; i++) { - if (vm->keyBindings[i].active == 0u) { - vm->keyBindings[i].key1 = a; - vm->keyBindings[i].key2 = b; - vm->keyBindings[i].controller = c; - vm->keyBindings[i].active = 1u; - break; - } - } - break; - } - - // ----- picture composition ----- - case OP_ADD_TO_PIC: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e) || !readByte(vm, &f) || !readByte(vm, &g)) { - return VM_DISPATCH_RETURN; - } - if (vm->callbacks.addToPic != NULL) { - vm->callbacks.addToPic(vm->callbacks.ctx, a, b, c, d, e, f, g); - } - break; - - case OP_ADD_TO_PIC_V: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e) || !readByte(vm, &f) || !readByte(vm, &g)) { - return VM_DISPATCH_RETURN; - } - if (vm->callbacks.addToPic != NULL) { - vm->callbacks.addToPic(vm->callbacks.ctx, - vm->vars[a], vm->vars[b], vm->vars[c], - vm->vars[d], vm->vars[e], vm->vars[f], vm->vars[g]); - } - break; - - // ----- misc state / no-ops ----- - case OP_STATUS: - break; - - case OP_SAVE_GAME: - case OP_RESTORE_GAME: - break; - - case OP_INIT_DISK: - break; - - case OP_RESTART_GAME: - vm->haltReason = AGI_VM_HALT_QUIT; - return VM_DISPATCH_RETURN; - - case OP_SHOW_OBJ: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_RANDOM: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - { - uint16_t span; - span = (uint16_t)((b > a) ? (b - a + 1u) : 1u); - vm->vars[c] = (uint8_t)(a + (uint8_t)(jlRandomRange(span))); - } - break; - - case OP_PROGRAM_CONTROL: - vm->programControl = true; - break; - - case OP_PLAYER_CONTROL: - vm->programControl = false; - break; - - case OP_OBJ_STATUS_V: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_QUIT: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - vm->haltReason = AGI_VM_HALT_QUIT; - return VM_DISPATCH_RETURN; - - case OP_SHOW_MEM: - break; - - case OP_PAUSE: - openPrintModal(vm, "Game Paused.\n\nPress ENTER to continue."); - return VM_DISPATCH_RETURN; - - case OP_ECHO_LINE: - case OP_CANCEL_LINE: - break; - - case OP_INIT_JOY: - break; - - case OP_TOGGLE_MONITOR: - break; - - case OP_VERSION: - break; - - case OP_SCRIPT_SIZE: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_SET_GAME_ID: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_LOG: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_SET_SCAN_START: - case OP_RESET_SCAN_START: - break; - - case OP_TRACE_ON: - break; - - case OP_TRACE_INFO: - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { - return VM_DISPATCH_RETURN; - } - break; - - case OP_PRINT_AT: { - char expanded[AGI_PRINT_MAX_LEN + 1u]; - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d)) { - return VM_DISPATCH_RETURN; - } - expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, a), expanded, sizeof(expanded)); - openPrintModal(vm, expanded); - return VM_DISPATCH_RETURN; - } - - case OP_PRINT_AT_V: { - char expanded[AGI_PRINT_MAX_LEN + 1u]; - if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d)) { - return VM_DISPATCH_RETURN; - } - expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, vm->vars[a]), expanded, sizeof(expanded)); - openPrintModal(vm, expanded); - return VM_DISPATCH_RETURN; - } - - case OP_SET_UPPER_LEFT: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - break; - - // ----- menus ----- - case OP_SET_MENU: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - if (vm->menuCount < AGI_MAX_MENUS) { - vm->menus[vm->menuCount].nameMsgId = a; - vm->menus[vm->menuCount].itemCount = 0u; - vm->menuCount++; - } - break; - - case OP_SET_MENU_ITEM: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (vm->menuCount > 0u) { - AgiMenuT *m = &vm->menus[vm->menuCount - 1u]; - if (m->itemCount < AGI_MAX_MENU_ITEMS) { - m->items[m->itemCount].messageId = a; - m->items[m->itemCount].controller = b; - m->items[m->itemCount].enabled = 1u; - m->itemCount++; - } - } - break; - - case OP_SUBMIT_MENU: - break; - - case OP_ENABLE_ITEM: - case OP_DISABLE_ITEM: { - uint8_t m; - uint8_t i; - uint8_t enable; - - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - enable = (op == OP_ENABLE_ITEM) ? 1u : 0u; - for (m = 0u; m < vm->menuCount; m++) { - for (i = 0u; i < vm->menus[m].itemCount; i++) { - if (vm->menus[m].items[i].controller == a) { - vm->menus[m].items[i].enabled = enable; - } - } - } - break; - } - - case OP_MENU_INPUT: - // Real menu UI is not wired; just no-op so scripts don't stall. - break; - - case OP_SHOW_OBJ_V: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - case OP_OPEN_DIALOGUE: - case OP_CLOSE_DIALOGUE: - break; - - case OP_MUL_N: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - vm->vars[a] = (uint8_t)(vm->vars[a] * b); - break; - - case OP_MUL_V: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - vm->vars[a] = (uint8_t)(vm->vars[a] * vm->vars[b]); - break; - - case OP_DIV_N: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (b != 0u) { - vm->vars[a] = (uint8_t)(vm->vars[a] / b); - } - break; - - case OP_DIV_V: - if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; } - if (vm->vars[b] != 0u) { - vm->vars[a] = (uint8_t)(vm->vars[a] / vm->vars[b]); - } - break; - - case OP_CLOSE_WINDOW: - vm->printModal.active = false; - break; - - case OP_SET_SIMPLE: - if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; } - break; - - default: { - uint8_t argBytes; - uint8_t i; - uint8_t scratch; - - argBytes = kActionArgBytes[op]; - if (argBytes == ACTION_UNKNOWN) { - vm->lastUnknownOp = op; - vm->haltReason = AGI_VM_HALT_UNKNOWN_OP; - vm->pc = (uint16_t)(vm->pc - 1u); - break; - } - for (i = 0u; i < argBytes; i++) { - if (!readByte(vm, &scratch)) { - return VM_DISPATCH_RETURN; - } - } - break; - } - } - return VM_DISPATCH_CONTINUE; -} - - -void agiVmSetCallbacks(AgiVmT *vm, const AgiVmCallbacksT *callbacks) { - if (callbacks == NULL) { - memset(&vm->callbacks, 0, sizeof(vm->callbacks)); - } else { - vm->callbacks = *callbacks; +// Rebuild one recorded picture command (restore). +void vmReplay(const AgiScriptEntryT *e) { + const uint8_t *a = e->args; + + switch (e->op) { + case CMD_LOAD_LOGICS: + (void)vmLoadLogic(a[0]); + break; + case CMD_LOAD_VIEW: + (void)agiViewGet(a[0]); + break; + case CMD_DRAW_PIC: + case CMD_OVERLAY_PIC: + gfxDrawPic(a[0], e->op == CMD_DRAW_PIC); + break; + case CMD_ADD_TO_PIC: + gfxAddToPic(a[0], a[1], a[2], a[3], a[4], a[5], a[6]); + break; + default: + break; } } - - -void agiVmTickSeconds(AgiVmT *vm, uint8_t seconds) { - uint16_t s; - uint16_t m; - uint16_t h; - uint16_t carry; - - if (seconds == 0u) { - return; - } - // Sound completion is no longer driven from here -- the per-cycle - // poll in agiVmTickAnimation reads callbacks.isPlayingSound and - // fires the done flags on the live host->silent edge. Keeps the - // VM in lock-step with what the user actually hears. - - s = (uint16_t)((uint16_t)vm->vars[11] + (uint16_t)seconds); - carry = (uint16_t)(s / 60u); - vm->vars[11] = (uint8_t)(s % 60u); - if (carry == 0u) { - return; - } - m = (uint16_t)((uint16_t)vm->vars[12] + carry); - carry = (uint16_t)(m / 60u); - vm->vars[12] = (uint8_t)(m % 60u); - if (carry == 0u) { - return; - } - h = (uint16_t)((uint16_t)vm->vars[13] + carry); - vm->vars[13] = (uint8_t)(h % 24u); -} diff --git a/games/README.md b/games/README.md index a232eff..8fe27bf 100644 --- a/games/README.md +++ b/games/README.md @@ -31,6 +31,10 @@ 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 +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. + File names are uppercase by AGI convention; the loader looks them up literally so the directory should match. diff --git a/program.mk b/program.mk index 5b28559..923adb6 100644 --- a/program.mk +++ b/program.mk @@ -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 agiVm.c agiObj.c agiText.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) AGI_TITLE := AGI AGI_HELP := A Sierra AGI interpreter. The files of the game it plays go in its DATA folder. diff --git a/scripts/make-agi-iigs-disk.sh b/scripts/make-agi-iigs-disk.sh index ea39d66..47b0c5f 100755 --- a/scripts/make-agi-iigs-disk.sh +++ b/scripts/make-agi-iigs-disk.sh @@ -56,10 +56,11 @@ cp "$agiBin" "$work/AGI#B30000" "$CADIUS" CREATEFOLDER "$out" "/$VOL/DATA" >/dev/null # Game resources as plain ProDOS BIN ($06). Only the subset AGI v2 reads -# (the four DIR indexes, the VOL.x archives, OBJECT, WORDS.TOK) is copied; -# Sierra's original binary / OVL drivers are left behind. +# (the four DIR indexes, the VOL.x archives, OBJECT, WORDS.TOK, and +# AGIDATA.OVL for the interpreter version its quirks follow) is copied; +# Sierra's original binary and the other OVL drivers are left behind. added=0 -for f in LOGDIR PICDIR VIEWDIR SNDDIR OBJECT WORDS.TOK; do +for f in LOGDIR PICDIR VIEWDIR SNDDIR OBJECT WORDS.TOK AGIDATA.OVL; do if [ -f "$gamedata/$f" ]; then cp "$gamedata/$f" "$work/${f}#060000" "$CADIUS" ADDFILE "$out" "/$VOL/DATA" "$work/${f}#060000" >/dev/null diff --git a/scripts/oracle.sh b/scripts/oracle.sh new file mode 100755 index 0000000..168ac8a --- /dev/null +++ b/scripts/oracle.sh @@ -0,0 +1,124 @@ +#!/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. +# +# 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 +# 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 +# shot, reading text with each interpreter's own font. +# +# 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. +# +# 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 +# session when none is named +# 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) +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 +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; } +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) +probes=${*:-$(python3 "$repo/tests/probes.py" list) $sessions} +mkdir -p "$build" + +# One probe on both interpreters. +one() { + local name=$1 + local game=$build/$name/game + local run + local script + 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 + 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/" + script=$game/probe.scr + fi + timeout -s KILL 900 python3 "$tools/dosDrive.py" "$game" KQ3.COM "$build/$name/oracle" "$script" \ + --set joysticktype=none > "$build/$name/oracle.log" 2>&1 + run=$(mktemp -d "$build/run.XXXXXX") + ln -s "$game" "$run/DATA" + mkdir "$run/SAVES" + (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 + +# The fonts first: every comparison reads text with them. +one font +printf '%s\n' $probes | grep -vx font | xargs -P "$parallel" -I{} bash -c 'one {}' + +fontArgs=(--fonts "$build/font/oracle/font.png" "$build/font/joey/font.ppm") +total=0 +differ=0 +for name in $probes; do + echo "=== $name" + for shot in "$build/$name"/oracle/*.png; do + [[ -f $shot ]] || continue + base=$(basename "${shot%.png}") + joey=$build/$name/joey/$base.ppm + total=$((total + 1)) + if [[ ! -f $joey ]]; then + echo " $base: JoeyAGI took no such shot" + differ=$((differ + 1)) + continue + fi + if result=$(python3 "$tools/compareFrames.py" "$shot" "$joey" "$build/$name/diff-$base.png" "${fontArgs[@]}"); then + echo " $base: same" + else + echo " $base: DIFFERS $result" + differ=$((differ + 1)) + fi + done + # Recordings (shake.screen): the shakes read out of both. + for video in "$build/$name"/oracle/*.avi; do + [[ -f $video ]] || continue + base=$(basename "${video%.avi}") + total=$((total + 1)) + if result=$(python3 "$tools/shakeMeasure.py" "$video" "$build/$name/joey/$base.frames"); then + echo " $base (recording): same" + else + echo " $base (recording): DIFFERS" + differ=$((differ + 1)) + fi + echo "$result" | sed 's/^/ /' + done +done +echo +echo "oracle: $((total - differ)) of $total shots match (diff images in $build//)" +[[ $differ == 0 ]] diff --git a/scripts/test-agi.sh b/scripts/test-agi.sh index 227f76e..8f2eafe 100755 --- a/scripts/test-agi.sh +++ b/scripts/test-agi.sh @@ -1,205 +1,204 @@ #!/usr/bin/env bash -# Host-side regression tests for the AGI port. +# test-agi.sh - JoeyAGI's host gate: the whole interpreter, built for the +# build machine on JoeyLib's headless BLANK port (tests/hostPort.c), plays +# scripted sessions and every frame it is asked for must hash as expected. # -# Compiles the tests in tests/ with the interpreter sources they cover, -# using the host gcc and JoeyLib's headers, and runs them against every -# game directory in games/. Each game is exercised in isolation; the -# script aggregates pass/fail counts and exits non-zero if any test -# fails. +# 1. testResources: every resource of every game in games/ loads, every +# PICTURE decodes and every VIEW parses; KQ3's pictures (visual and +# priority) must hash as tests/expected/resources-kq3.txt says -- +# those frames were checked pixel for pixel against Sierra's +# interpreter. +# 2. The probes: small games tests/probes.py builds from scratch, one per +# 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/. # -# Why a host build instead of running the emulator binaries: the AGI -# resource loader is plain stdio C with no platform dependencies, so -# the parsing logic is identical on every JoeyLib target. Validating -# it on the build host means each iteration takes a second instead -# of the 15-30s of an emulator boot, and reproducible failures don't -# depend on capturing video / log output through DOSBox or MAME. +# 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 # test every subdir of games/ -# scripts/test-agi.sh kq3 agidemo # test only the named subdirs -# Env: -# JOEYLIB the JoeyLib checkout (default ../joeylib) - -set -euo pipefail +# Usage: scripts/test-agi.sh [--update] [NAME...] +# NAME is a probe, kq3-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) +# Exit: 0 pass, 1 fail, 2 skipped (no JoeyLib / host compiler / python3) +set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) JOEYLIB=${JOEYLIB:-$repo/../joeylib} [[ -f $JOEYLIB/include/joey/joey.h ]] || { echo "test-agi: no JoeyLib checkout at $JOEYLIB (set JOEYLIB)" >&2; exit 2; } -gamedata=$repo/games +command -v cc >/dev/null || { echo "test-agi: no host C compiler" >&2; exit 2; } +command -v python3 >/dev/null || { echo "test-agi: no python3" >&2; exit 2; } + +update=0 +if [[ ${1:-} == --update ]]; then + update=1 + shift +fi +only=" $* " + build=$repo/build/tests -testRes=$build/testAgiRes -testPic=$build/testAgiPic -testView=$build/testAgiView -testVm=$build/testAgiVm -testPipeline=$build/testAgiPipeline +obj=$build/obj +out=$build/out +expected=$repo/tests/expected +mkdir -p "$obj" "$out" -mkdir -p "$build" +# ----- Build: JoeyLib's core and generic code with the host port ----- -# Shared host compile flags. agiPic.c calls jlDrawPixel in the blit -# step, which the host test never invokes; we stub it as an empty -# function via the link-line shim below so the host build can resolve -# the symbol without dragging in the JoeyLib runtime. -# Pick a JoeyLib platform for the host build. None of the platform- -# specific code paths are exercised by the host test (it only touches -# the AGI parsers, never the JoeyLib HAL), but joey/platform.h #errors -# unless exactly one is defined. DOS is the closest fit for a Linux -# host (chunky, little-endian, x86 lineage). -hostCflags=(-std=c99 -Wall -Wextra -Werror -O2 -DJOEYLIB_PLATFORM_DOS -I "$JOEYLIB/include" -I "$repo" -I "$JOEYLIB/src/core") -stubSrc=$build/hostStubs.c -cat > "$stubSrc" <<'EOF' -#include "joey/draw.h" -#include "joey/core.h" -#include "joey/file.h" -#include "surfaceInternal.h" -#include -#include -#include -// Host jlDataOpen: the real one (assetLoad.c) forces a DATA/ prefix, but -// the test gamedata dirs hold their files flat. Open / -// so agiRes.c's bare-name jlDataOpen calls resolve against the game dir the -// harness points us at. -FILE *jlDataOpen(const char *name, const char *mode) { - const char *base = getenv("AGI_DATA_DIR"); - char path[1024]; - if (base == NULL) { base = "."; } - if ((size_t)snprintf(path, sizeof(path), "%s/%s", base, name) >= sizeof(path)) { return NULL; } - return fopen(path, mode); -} -void jlDrawPixel(jlSurfaceT *s, int16_t x, int16_t y, uint8_t c) { (void)s; (void)x; (void)y; (void)c; } -void jlFillRect(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t c) { (void)s; (void)x; (void)y; (void)w; (void)h; (void)c; } -uint32_t jlRandom(void) { return (uint32_t)rand(); } -uint16_t jlRandomRange(uint16_t bound) { return (bound == 0u) ? 0u : (uint16_t)((unsigned)rand() % bound); } -void jlRandomSeed(uint32_t seed) { srand((unsigned)seed); } -void *jlAlloc(uint32_t bytes) { return malloc((size_t)bytes); } -void jlFree(void *p) { free(p); } -void jlLogF(const char *fmt, ...) { (void)fmt; } -void jlLogFlush(void) { } -void jlLog(const char *msg) { (void)msg; } -/* surfaceMarkDirtyRect is a MACRO since the Phase-6 mark rework -- the - * host TUs (agiPic/agiView) expand it inline, so the stubs provide the - * band state it references instead of a function. gStage stays NULL, - * so every expansion takes the s != gStage no-op branch; the symbols - * only need to exist to link. */ -jlSurfaceT *gStage = 0; -uint8_t gStageMinWord[SURFACE_HEIGHT]; -uint8_t gStageMaxWord[SURFACE_HEIGHT]; -void surfaceMarkDirtyRows(uint16_t y, uint16_t yEnd, uint8_t minWord, uint8_t maxWord) { (void)y; (void)yEnd; (void)minWord; (void)maxWord; } -void surfaceMarkDirtyAll(const jlSurfaceT *s) { (void)s; } -EOF +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) -echo "Building host tests..." -gcc "${hostCflags[@]}" \ - "$repo/tests/testAgiRes.c" \ - "$repo/agiRes.c" \ - "$stubSrc" \ - -o "$testRes" - -gcc "${hostCflags[@]}" \ - "$repo/tests/testAgiPic.c" \ - "$repo/agiRes.c" \ - "$repo/agiPic.c" \ - "$stubSrc" \ - -o "$testPic" - -gcc "${hostCflags[@]}" \ - "$repo/tests/testAgiView.c" \ - "$repo/agiRes.c" \ - "$repo/agiPic.c" \ - "$repo/agiView.c" \ - "$stubSrc" \ - -o "$testView" - -gcc "${hostCflags[@]}" \ - "$repo/tests/testAgiVm.c" \ - "$repo/agiRes.c" \ - "$repo/agiVm.c" \ - "$repo/agiObj.c" \ - "$stubSrc" \ - -o "$testVm" - -gcc "${hostCflags[@]}" \ - "$repo/tests/testAgiPipeline.c" \ - "$repo/agiRes.c" \ - "$repo/agiVm.c" \ - "$repo/agiObj.c" \ - "$stubSrc" \ - -o "$testPipeline" - -# Pick the game directories to exercise. With no args, every -# subdirectory of games/ that contains LOGDIR is a candidate (skips -# stray README files etc). -if [[ $# -gt 0 ]]; then - games=("$@") -else - games=() - if [[ -d $gamedata ]]; then - for dir in "$gamedata"/*/; do - [[ -d $dir ]] || continue - name=$(basename "$dir") - if [[ -f $dir/LOGDIR ]]; then - games+=("$name") - fi - done +# compile SRC OBJ FLAGS...: when the source, or any header, is newer. +compile() { + local src=$1 o=$2 + shift 2 + if [[ -f $o && ! $src -nt $o ]] && [[ -z $(find "$repo" "$repo/tests" "$JOEYLIB/include" "$JOEYLIB/src/core" -maxdepth 2 -name '*.h' -newer "$o" -print -quit) ]]; then + return 0 fi -fi + cc "$@" -c "$src" -o "$o" +} -if [[ ${#games[@]} -eq 0 ]]; then - echo - echo "No AGI game directories found under $gamedata" - echo "Drop an AGI v2 game in games// and rerun." - echo "See games/README.md for the required files." - # Nothing was tested: not a pass (a gate runner reads 2 as skipped). - exit 2 -fi +echo "Building the host interpreter..." +ok=1 +jlObjs=() +for f in "$JOEYLIB"/src/core/*.c "$JOEYLIB"/src/generic/*.c; do + o=$obj/jl_$(basename "${f%.c}").o + compile "$f" "$o" "${jlFlags[@]}" -w || ok=0 + jlObjs+=("$o") +done +agiObjs=() +for f in "${engine[@]}"; do + o=$obj/$f.o + if [[ $f == agi ]]; then + compile "$repo/$f.c" "$o" "${agiFlags[@]}" -Dmain=agiMain || ok=0 + else + compile "$repo/$f.c" "$o" "${agiFlags[@]}" || ok=0 + fi + agiObjs+=("$o") +done +for f in hostPort agiHost testResources; do + compile "$repo/tests/$f.c" "$obj/$f.o" "${agiFlags[@]}" || ok=0 +done +[[ $ok == 1 ]] || { echo "test-agi: FAIL (build)" >&2; exit 1; } +cc -o "$build/agiHost" "$obj/agiHost.o" "$obj/hostPort.o" "${agiObjs[@]}" "${jlObjs[@]}" -lm || exit 1 +cc -o "$build/testResources" "$obj/testResources.o" "$obj/hostPort.o" "${agiObjs[@]}" "${jlObjs[@]}" -lm || exit 1 passed=0 failed=0 -echo -for game in "${games[@]}"; do - dir=$gamedata/$game - echo "===== $game =====" - if [[ ! -d $dir ]]; then - echo " FAIL: $dir does not exist" +skipped=0 + +# check NAME EXPECTFILE ACTUALFILE: compare, or with --update record. +check() { + local name=$1 want=$2 got=$3 + if [[ $update == 1 ]]; then + mkdir -p "$(dirname "$want")" + cp "$got" "$want" + echo " $name: recorded $(wc -l < "$got") lines" + passed=$((passed + 1)) + return + fi + if [[ ! -f $want ]]; then + echo " $name: FAIL (no $want; record it with --update once the frames are checked)" failed=$((failed + 1)) - continue + return fi - - # agiRes.c opens data by bare name via jlDataOpen; the host stub - # resolves those against AGI_DATA_DIR (see stubSrc above). - export AGI_DATA_DIR="$dir" - gameOk=1 - echo "--- resource loader ---" - if ! "$testRes" "$dir"; then - gameOk=0 - fi - echo "--- PIC decoder ---" - if ! "$testPic" "$dir"; then - gameOk=0 - fi - echo "--- VIEW decoder ---" - if ! "$testView" "$dir"; then - gameOk=0 - fi - echo "--- LOGIC VM (logic.0) ---" - if ! "$testVm" "$dir"; then - gameOk=0 - fi - echo "--- VM pipeline (callbacks + room transitions) ---" - if ! "$testPipeline" "$dir"; then - gameOk=0 - fi - - if [[ $gameOk -eq 1 ]]; then + if diff -q "$want" "$got" >/dev/null; then + echo " $name: PASS ($(wc -l < "$got") checked)" passed=$((passed + 1)) else + echo " $name: FAIL -- what differs (< expected, > got); frames in $out/$name/" + diff "$want" "$got" | grep '^[<>]' | head -20 | sed 's/^/ /' failed=$((failed + 1)) fi - echo +} + +# selected NAME: was it asked for (everything is when nothing was named)? +selected() { + [[ $only == " " || $only == *" $1 "* ]] +} + +# play NAME GAMEDIR SCRIPT EXPECTFILE: one scripted session. +play() { + local name=$1 game=$2 script=$3 want=$4 run + selected "$name" || return 0 + run=$(mktemp -d "$build/run.XXXXXX") + ln -s "$game" "$run/DATA" + # The host port cannot make directories; saving needs SAVES/ there. + mkdir "$run/SAVES" + rm -rf "${out:?}/$name" + mkdir -p "$out/$name" + (cd "$run" && timeout -s KILL 600 "$build/agiHost" "$script" "$out/$name") > "$out/$name/log.txt" 2>&1 + rm -rf "$run" + sed -n 's/^\[ *[0-9.]*\] shot \([^ ]*\) \([0-9a-f]*\)$/\1 \2/p' "$out/$name/log.txt" > "$out/$name/hashes.txt" + if ! grep -q 'script done' "$out/$name/log.txt"; then + echo " $name: FAIL (the run stopped before its script ended; see $out/$name/log.txt)" + failed=$((failed + 1)) + return + fi + check "$name" "$want" "$out/$name/hashes.txt" +} + +# ----- 1. Resources ----- + +echo +echo "--- resources ---" +for dir in "$repo"/games/*/; do + [[ -f $dir/LOGDIR ]] || continue + game=$(basename "$dir") + name=resources-$game + selected "$name" || continue + run=$(mktemp -d "$build/run.XXXXXX") + ln -s "$dir" "$run/DATA" + mkdir -p "$out/$name" + (cd "$run" && timeout -s KILL 600 "$build/testResources") > "$out/$name/hashes.txt" 2> "$out/$name/log.txt" + status=$? + rm -rf "$run" + if [[ $status != 0 ]]; then + echo " $name: FAIL ($(tail -1 "$out/$name/log.txt"))" + failed=$((failed + 1)) + elif [[ -f $expected/$name.txt || $update == 1 ]]; then + check "$name" "$expected/$name.txt" "$out/$name/hashes.txt" + else + echo " $name: PASS ($(tail -1 "$out/$name/log.txt"); no expected picture hashes for this game)" + passed=$((passed + 1)) + fi done -total=$((passed + failed)) -echo "===== summary: $passed/$total passed =====" +# ----- 2. Probes ----- + +echo +echo "--- probes ---" +python3 "$repo/tests/probes.py" build "$build/probes" || { echo "test-agi: FAIL (probes.py)" >&2; exit 1; } +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 ----- + +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" + skipped=1 +fi + +echo +echo "===== test-agi: $passed passed, $failed failed =====" if [[ $failed -gt 0 ]]; then exit 1 fi +if [[ $skipped -gt 0 && $passed -eq 0 ]]; then + exit 2 +fi exit 0 diff --git a/tests/agiGame.py b/tests/agiGame.py new file mode 100644 index 0000000..3dc9be0 --- /dev/null +++ b/tests/agiGame.py @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +# agiGame.py - build small AGI v2 games from scratch for JoeyAGI's tests: +# LOGIC code from a Python description, PICTURE command lists, VIEWs from +# text art, SOUNDs, WORDS.TOK, OBJECT, the four directories and VOL.0. +# +# Everything here follows the public AGI specifications (the LOGIC command +# table, chapter 5's directory and VOL formats, chapters 6-10). Nothing is +# copied from a Sierra game, so the games it writes can live in the repo's +# tests and be fed to Sierra's own interpreter as well (scripts/oracle.sh). +# +# L = Logic() +# with L.If(("isset", 5)): +# L.load_pic(...) # commands by name, '.' written as '_' +# L.return_() +# writeGame(dir, logics={0: L.build()}, pics={...}, views={...}) + +import os +import struct + +# Action commands in number order, with their argument counts. +ACTIONS = """return 0 +increment 1 +decrement 1 +assignn 2 +assignv 2 +addn 2 +addv 2 +subn 2 +subv 2 +lindirectv 2 +rindirect 2 +lindirectn 2 +set 1 +reset 1 +toggle 1 +set.v 1 +reset.v 1 +toggle.v 1 +new.room 1 +new.room.v 1 +load.logics 1 +load.logics.v 1 +call 1 +call.v 1 +load.pic 1 +draw.pic 1 +show.pic 0 +discard.pic 1 +overlay.pic 1 +show.pri.screen 0 +load.view 1 +load.view.v 1 +discard.view 1 +animate.obj 1 +unanimate.all 0 +draw 1 +erase 1 +position 3 +position.v 3 +get.posn 3 +reposition 3 +set.view 2 +set.view.v 2 +set.loop 2 +set.loop.v 2 +fix.loop 1 +release.loop 1 +set.cel 2 +set.cel.v 2 +last.cel 2 +current.cel 2 +current.loop 2 +current.view 2 +number.of.loops 2 +set.priority 2 +set.priority.v 2 +release.priority 1 +get.priority 2 +stop.update 1 +start.update 1 +force.update 1 +ignore.horizon 1 +observe.horizon 1 +set.horizon 1 +object.on.water 1 +object.on.land 1 +object.on.anything 1 +ignore.objs 1 +observe.objs 1 +distance 3 +stop.cycling 1 +start.cycling 1 +normal.cycle 1 +end.of.loop 2 +reverse.cycle 1 +reverse.loop 2 +cycle.time 2 +stop.motion 1 +start.motion 1 +step.size 2 +step.time 2 +move.obj 5 +move.obj.v 5 +follow.ego 3 +wander 1 +normal.motion 1 +set.dir 2 +get.dir 2 +ignore.blocks 1 +observe.blocks 1 +block 4 +unblock 0 +get 1 +get.v 1 +drop 1 +put 2 +put.v 2 +get.room.v 2 +load.sound 1 +sound 2 +stop.sound 0 +print 1 +print.v 1 +display 3 +display.v 3 +clear.lines 3 +text.screen 0 +graphics 0 +set.cursor.char 1 +set.text.attribute 2 +shake.screen 1 +configure.screen 3 +status.line.on 0 +status.line.off 0 +set.string 2 +get.string 5 +word.to.string 2 +parse 1 +get.num 2 +prevent.input 0 +accept.input 0 +set.key 3 +add.to.pic 7 +add.to.pic.v 7 +status 0 +save.game 0 +restore.game 0 +init.disk 0 +restart.game 0 +show.obj 1 +random 3 +program.control 0 +player.control 0 +obj.status.v 1 +quit 1 +show.mem 0 +pause 0 +echo.line 0 +cancel.line 0 +init.joy 0 +toggle.monitor 0 +version 0 +script.size 1 +set.game.id 1 +log 1 +set.scan.start 0 +reset.scan.start 0 +reposition.to 3 +reposition.to.v 3 +trace.on 0 +trace.info 3 +print.at 4 +print.at.v 4 +discard.view.v 1 +clear.text.rect 5 +set.upper.left 2 +set.menu 1 +set.menu.item 2 +submit.menu 0 +enable.item 1 +disable.item 1 +menu.input 0 +show.obj.v 1 +open.dialogue 0 +close.dialogue 0 +mul.n 2 +mul.v 2 +div.n 2 +div.v 2 +close.window 0""" + +# Test commands in number order (said takes a word list). +TESTS = """_ 0 +equaln 2 +equalv 2 +lessn 2 +lessv 2 +greatern 2 +greaterv 2 +isset 1 +isset.v 1 +has 1 +obj.in.room 2 +posn 5 +controller 1 +have.key 0 +said -1 +compare.strings 2 +obj.in.box 5 +center.posn 5 +right.posn 5""" + +ACT = {} +for _i, _line in enumerate(ACTIONS.splitlines()): + _n, _c = _line.split() + ACT[_n] = (_i, int(_c)) +TST = {} +for _i, _line in enumerate(TESTS.splitlines()): + _n, _c = _line.split() + TST[_n] = (_i, int(_c)) + +CRYPT_KEY = b"Avis Durgan" +COND_START = 0xFF +COND_END = 0xFF +COND_OR = 0xFC +COND_NOT = 0xFD +CODE_GOTO = 0xFE +VOL_SIGNATURE = b"\x12\x34" +DIR_EMPTY = b"\xff\xff\xff" +LETTERS = 26 +WORD_CHAR_XOR = 0x7F +WORD_LAST_CHAR = 0x80 +VOICES = 4 +SOUND_END = b"\xff\xff" + +# PICTURE commands (specification 7.2). +PIC_COLOR = 0xF0 +PIC_NO_COLOR = 0xF1 +PIC_PRIORITY = 0xF2 +PIC_NO_PRIORITY = 0xF3 +PIC_Y_CORNER = 0xF4 +PIC_X_CORNER = 0xF5 +PIC_LINE = 0xF6 +PIC_REL_LINE = 0xF7 +PIC_FILL = 0xF8 +PIC_PEN = 0xF9 +PIC_PLOT = 0xFA +PIC_END = 0xFF + + +def crypt(data): + return bytes(c ^ CRYPT_KEY[i % len(CRYPT_KEY)] for i, c in enumerate(data)) + + +class Logic: + def __init__(self): + self.code = bytearray() + self.msgs = [] + + def m(self, text): + # The message number for text (from 1), added on first use. + if text not in self.msgs: + self.msgs.append(text) + return self.msgs.index(text) + 1 + + def __getattr__(self, name): + cmd = name.rstrip("_").replace("_", ".") + if cmd not in ACT: + raise AttributeError(name) + + def emit(*args): + op, n = ACT[cmd] + if len(args) != n: + raise ValueError("%s takes %d arguments" % (cmd, n)) + self.code.append(op) + for a in args: + self.code.append(a & 0xFF) + return emit + + def cond(self, tests): + # A term is ("name", args...), ("!", "name", args...) or + # ("or", [terms]); said takes word numbers. + out = bytearray([COND_START]) + + def term(t): + b = bytearray() + if t[0] == "or": + b.append(COND_OR) + for s in t[1]: + b += term(s) + b.append(COND_OR) + return b + if t[0] == "!": + b.append(COND_NOT) + t = t[1:] + num, n = TST[t[0]] + b.append(num) + if t[0] == "said": + b.append(len(t) - 1) + for w in t[1:]: + b += struct.pack("> 4) & 0x3F, 0x80 | (v << 5) | (divisor & 0x0F)]) + body += struct.pack("H", out, 2 * letter)[0] == 0: + struct.pack_into(">H", out, 2 * letter, len(out)) + prev = "" + share = 0 + limit = min(len(prev), len(w) - 1) + while share < limit and prev[share] == w[share]: + share += 1 + out.append(share) + tail = w[share:] + for k, ch in enumerate(tail): + b = ord(ch) ^ WORD_CHAR_XOR + if k == len(tail) - 1: + b |= WORD_LAST_CHAR + out.append(b) + out += struct.pack(">H", table[w]) + prev = w + out.append(0) + return bytes(out) + + +def objects(items, maxObjects=16): + # OBJECT from [(name, room)]: the header, an entry per item, the + # names; offsets count from the first entry. Encrypted. + entries = bytearray() + names = bytearray() + tableLen = 3 * len(items) + for name, room in items: + entries += struct.pack("> 16) & 0x0F, (off >> 8) & 0xFF, off & 0xFF]) + with open(os.path.join(path, dirName), "wb") as f: + f.write(d) + with open(os.path.join(path, "VOL.0"), "wb") as f: + f.write(vol) + with open(os.path.join(path, "WORDS.TOK"), "wb") as f: + f.write(words(vocabulary or {"a": 0})) + with open(os.path.join(path, "OBJECT"), "wb") as f: + f.write(objects(items or [("?", 0)], maxObjects)) diff --git a/tests/agiHost.c b/tests/agiHost.c new file mode 100644 index 0000000..c0dcdad --- /dev/null +++ b/tests/agiHost.c @@ -0,0 +1,28 @@ +// agiHost: JoeyAGI on the build machine, played by an input script. +// +// agiHost SCRIPT OUTDIR run from a folder holding DATA/ with the game; +// OUTDIR "-" prints the shots' hashes only +// +// The interpreter's own main is compiled as agiMain (-Dmain=agiMain). + +#include "hostPort.h" + +#include + + +int agiMain(int argc, char **argv); + + +int main(int argc, char **argv) { + char *agiArgv[1]; + + if (argc != 3) { + fprintf(stderr, "usage: agiHost SCRIPT OUTDIR (run from a folder holding DATA/)\n"); + return 2; + } + if (!hostScriptOpen(argv[1], argv[2])) { + return 2; + } + agiArgv[0] = argv[0]; + return agiMain(1, agiArgv); +} diff --git a/tests/disasmAgi.c b/tests/disasmAgi.c deleted file mode 100644 index 2963877..0000000 --- a/tests/disasmAgi.c +++ /dev/null @@ -1,413 +0,0 @@ -// AGI v2 LOGIC disassembler. -// -// Walks one LOGIC resource and prints the bytecode in a human-readable -// form, with names for action opcodes, test commands, common variables -// and flags. Intended as a debugging aid while filling out the VM: -// dump KQ3's logic.0 + logic.45 to identify which stub opcodes are -// gating title-screen progression. -// -// Usage: -// disasmAgi [end-pc] -// -// `end-pc` caps the walk at a given offset; useful for spelunking -// through huge logic.0 without dumping the whole thing. - -#include "agi.h" - -#include -#include -#include - - -#define AGI_LOGIC_HDR_BYTES 2u -#define ACTION_UNKNOWN 0xFFu -#define MAX_TEST 0x12u - - -// ----- Prototypes ----- - -static const char *actionName(uint8_t op); -static uint16_t disasmAction(const uint8_t *code, uint16_t pc, uint16_t length, uint8_t op); -static uint16_t disasmIf(const uint8_t *code, uint16_t pc, uint16_t length); -static uint16_t disasmTest(const uint8_t *code, uint16_t pc, uint16_t length); -static const char *testName(uint8_t op); -static int walk(const uint8_t *code, uint16_t length, uint16_t endPc); - - -// AGI v2 action-opcode names. Indexed by opcode id; NULL means "no -// such opcode" (matches the VM's ACTION_UNKNOWN sentinel). -static const char *kActionNames[256] = { - /* 0x00 */ "return", "increment", "decrement", "assignn", - /* 0x04 */ "assignv", "addn", "addv", "subn", - /* 0x08 */ "subv", "lindirectv", "rindirect", "lindirectn", - /* 0x0C */ "set", "reset", "toggle", "setv", - /* 0x10 */ "resetv", "togglev", "new.room", "new.room.v", - /* 0x14 */ "load.logic", "load.logic.v", "call", "call.v", - /* 0x18 */ "load.pic", "draw.pic", "show.pic", "discard.pic", - /* 0x1C */ "overlay.pic", "show.pri.screen", "load.view", "load.view.v", - /* 0x20 */ "discard.view", "animate.obj", "unanimate.all", "draw", - /* 0x24 */ "erase", "position", "position.v", "get.posn", - /* 0x28 */ "reposition", "set.view", "set.view.v", "set.loop", - /* 0x2C */ "set.loop.v", "fix.loop", "release.loop", "set.cel", - /* 0x30 */ "set.cel.v", "last.cel", "current.cel", "current.loop", - /* 0x34 */ "current.view", "number.of.loops", "set.priority", "set.priority.v", - /* 0x38 */ "release.priority", "get.priority", "stop.update", "start.update", - /* 0x3C */ "force.update", "ignore.horizon", "observe.horizon", "set.horizon", - /* 0x40 */ "object.on.water", "object.on.land", "object.on.anything", "ignore.objs", - /* 0x44 */ "observe.objs", "distance", "stop.cycling", "start.cycling", - /* 0x48 */ "normal.cycle", "end.of.loop", "reverse.cycle", "reverse.loop", - /* 0x4C */ "cycle.time", "stop.motion", "start.motion", "step.size", - /* 0x50 */ "step.time", "move.obj", "move.obj.v", "follow.ego", - /* 0x54 */ "wander", "normal.motion", "set.dir", "get.dir", - /* 0x58 */ "ignore.blocks", "observe.blocks", "block", "unblock", - /* 0x5C */ "get", "get.v", "drop", "put", - /* 0x60 */ "put.v", "get.room.v", "load.sound", "sound", - /* 0x64 */ "stop.sound", "print", "print.v", "display", - /* 0x68 */ "display.v", "clear.lines", "text.screen", "graphics", - /* 0x6C */ "set.cursor.char", "set.text.attribute", "shake.screen", "configure.screen", - /* 0x70 */ "status.line.on", "status.line.off", "set.string", "get.string", - /* 0x74 */ "word.to.string", "parse", "get.num", "prevent.input", - /* 0x78 */ "accept.input", "set.key", "add.to.pic", "add.to.pic.v", - /* 0x7C */ "status", "save.game", "restore.game", "init.disk", - /* 0x80 */ "restart.game", "show.obj", "random", "program.control", - /* 0x84 */ "player.control", "obj.status.v", "quit", "show.mem", - /* 0x88 */ "pause", "echo.line", "cancel.line", "init.joy", - /* 0x8C */ "toggle.monitor", "version", "script.size", "set.game.id", - /* 0x90 */ "log", "set.scan.start", "reset.scan.start", "reposition.to", - /* 0x94 */ "reposition.to.v", "trace.on", "trace.info", "print.at", - /* 0x98 */ "print.at.v", "discard.view.v", "clear.text.rect", "set.upper.left", - /* 0x9C */ "set.menu", "set.menu.item", "submit.menu", "enable.item", - /* 0xA0 */ "disable.item", "menu.input", "show.obj.v", "open.dialogue", - /* 0xA4 */ "close.dialogue", "mul.n", "mul.v", "div.n", - /* 0xA8 */ "div.v", "close.window", "set.simple", "push.script", - /* 0xAC */ "pop.script", "hold.key", "set.pri.base", "discard.sound", - /* 0xB0 */ "hide.mouse", "allow.menu", "show.mouse", "fence.mouse", - /* 0xB4 */ "mouse.posn", "release.key", "adj.ego.move.to.x.y", - /* 0xB7..0xFD */ NULL - // 0xFE = goto, 0xFF = if -- handled separately -}; - - -// AGI v2 action arg byte counts (matches kActionArgBytes in agiVm.c). -static const uint8_t kActionArgBytes[256] = { - /* 0x00 */ 0u, 1u, 1u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 1u, 1u, 1u, 1u, - /* 0x10 */ 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 0u, 1u, 1u, 0u, 1u, 1u, - /* 0x20 */ 1u, 1u, 0u, 1u, 1u, 3u, 3u, 3u, 3u, 2u, 2u, 2u, 2u, 1u, 1u, 2u, - /* 0x30 */ 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 1u, 2u, 1u, 1u, 1u, 1u, 1u, 1u, - /* 0x40 */ 1u, 1u, 1u, 1u, 1u, 3u, 1u, 1u, 1u, 2u, 1u, 2u, 2u, 1u, 1u, 2u, - /* 0x50 */ 2u, 5u, 5u, 3u, 1u, 1u, 2u, 2u, 1u, 1u, 4u, 0u, 1u, 1u, 1u, 2u, - /* 0x60 */ 2u, 2u, 1u, 2u, 0u, 1u, 1u, 3u, 3u, 3u, 0u, 0u, 1u, 2u, 1u, 3u, - /* 0x70 */ 0u, 0u, 2u, 5u, 2u, 1u, 2u, 0u, 0u, 3u, 7u, 7u, 0u, 0u, 0u, 0u, - /* 0x80 */ 0u, 1u, 3u, 0u, 0u, 1u, 1u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 1u, 1u, - /* 0x90 */ 1u, 0u, 0u, 3u, 3u, 0u, 3u, 4u, 4u, 1u, 4u, 2u, 1u, 2u, 0u, 1u, - /* 0xA0 */ 1u, 0u, 1u, 0u, 0u, 2u, 2u, 2u, 2u, 0u, 1u, 0u, 0u, 0u, 1u, 1u, - /* 0xB0 */ 0u, 1u, 0u, 4u, 2u, 0u, 0u, - ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xBA */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xC0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xC8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xD0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xD8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xE0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xE8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xF0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, - /* 0xF8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN -}; - - -static const char *kTestNames[MAX_TEST + 1u] = { - /* 0x00 */ NULL, - /* 0x01 */ "equaln", - /* 0x02 */ "equalv", - /* 0x03 */ "lessn", - /* 0x04 */ "lessv", - /* 0x05 */ "greatern", - /* 0x06 */ "greaterv", - /* 0x07 */ "isset", - /* 0x08 */ "issetv", - /* 0x09 */ "has", - /* 0x0A */ "obj.in.room", - /* 0x0B */ "posn", - /* 0x0C */ "controller", - /* 0x0D */ "have.key", - /* 0x0E */ "said", - /* 0x0F */ "compare.strings", - /* 0x10 */ "obj.in.box", - /* 0x11 */ "center.posn", - /* 0x12 */ "right.posn" -}; - - -static const uint8_t kTestArgBytes[MAX_TEST + 1u] = { - 0u, 2u, 2u, 2u, 2u, 2u, 2u, 1u, 1u, 1u, 2u, 5u, 1u, 0u, 0u /*said*/, 2u, 5u, 5u, 5u -}; - - -// ----- Helpers (alphabetical) ----- - -static const char *actionName(uint8_t op) { - if (op == 0xFFu) { - return "if"; - } - if (op == 0xFEu) { - return "goto"; - } - if (kActionNames[op] != NULL) { - return kActionNames[op]; - } - return "???"; -} - - -static uint16_t disasmAction(const uint8_t *code, uint16_t pc, uint16_t length, uint8_t op) { - uint8_t args; - uint8_t i; - uint16_t here; - - here = (uint16_t)(pc - 1u); - args = kActionArgBytes[op]; - if (args == ACTION_UNKNOWN) { - printf(" %04X: %02X ???\n", (unsigned)here, (unsigned)op); - return length; - } - if ((uint32_t)pc + (uint32_t)args > (uint32_t)length) { - printf(" %04X: %02X truncated args\n", (unsigned)here, (unsigned)op); - return length; - } - printf(" %04X: %s", (unsigned)here, actionName(op)); - if (args == 0u) { - printf("()\n"); - } else { - printf("("); - for (i = 0u; i < args; i++) { - if (i > 0u) { - printf(", "); - } - printf("%u", (unsigned)code[pc + i]); - } - printf(")\n"); - } - return (uint16_t)(pc + args); -} - - -static uint16_t disasmIf(const uint8_t *code, uint16_t pc, uint16_t length) { - uint16_t here; - uint8_t testOp; - bool first; - bool notNext; - bool inOr; - uint16_t skip; - uint16_t target; - - here = (uint16_t)(pc - 1u); - printf(" %04X: if (", (unsigned)here); - first = true; - notNext = false; - inOr = false; - for (;;) { - if (pc >= length) { - printf(" )\n"); - return length; - } - testOp = code[pc]; - pc = (uint16_t)(pc + 1u); - if (testOp == 0xFFu) { - // End of test list. - break; - } - if (testOp == 0xFCu) { - if (inOr) { - printf(")"); - inOr = false; - } else { - if (!first) { - printf(" && "); - } - printf("("); - inOr = true; - first = true; - } - continue; - } - if (testOp == 0xFDu) { - notNext = true; - continue; - } - if (!first) { - printf(inOr ? " || " : " && "); - } - first = false; - if (notNext) { - printf("!"); - notNext = false; - } - pc = disasmTest(code, pc, length); - } - if (inOr) { - printf(")"); - } - if ((uint32_t)pc + 2u > (uint32_t)length) { - printf(") \n"); - return length; - } - skip = (uint16_t)(code[pc] | ((uint16_t)code[pc + 1u] << 8)); - target = (uint16_t)(pc + 2u + skip); - printf(") -> %04X\n", (unsigned)target); - return (uint16_t)(pc + 2u); -} - - -static uint16_t disasmTest(const uint8_t *code, uint16_t pc, uint16_t length) { - uint8_t args; - uint8_t i; - uint8_t testOp; - uint8_t wordCount; - uint16_t word; - - testOp = code[pc - 1u]; - if (testOp == 0u || testOp > MAX_TEST) { - printf("???_test_0x%02X(", (unsigned)testOp); - return pc; - } - if (testOp == 0x0Eu) { - // said(word, word, ...) - if (pc >= length) { - printf("said("); - return length; - } - wordCount = code[pc]; - pc = (uint16_t)(pc + 1u); - printf("said("); - for (i = 0u; i < wordCount; i++) { - if (i > 0u) { - printf(", "); - } - if ((uint32_t)pc + 2u > (uint32_t)length) { - printf(""); - return length; - } - word = (uint16_t)(code[pc] | ((uint16_t)code[pc + 1u] << 8)); - printf("w%u", (unsigned)word); - pc = (uint16_t)(pc + 2u); - } - printf(")"); - return pc; - } - args = kTestArgBytes[testOp]; - printf("%s(", testName(testOp)); - if ((uint32_t)pc + (uint32_t)args > (uint32_t)length) { - printf(")"); - return length; - } - for (i = 0u; i < args; i++) { - if (i > 0u) { - printf(", "); - } - printf("%u", (unsigned)code[pc + i]); - } - printf(")"); - return (uint16_t)(pc + args); -} - - -static const char *testName(uint8_t op) { - if (op > MAX_TEST || kTestNames[op] == NULL) { - return "???"; - } - return kTestNames[op]; -} - - -static int walk(const uint8_t *code, uint16_t length, uint16_t endPc) { - uint16_t pc; - uint8_t op; - int16_t gotoOff; - uint16_t target; - - pc = 0u; - while (pc < length && pc < endPc) { - op = code[pc]; - pc = (uint16_t)(pc + 1u); - if (op == 0xFFu) { - pc = disasmIf(code, pc, length); - continue; - } - if (op == 0xFEu) { - if ((uint32_t)pc + 2u > (uint32_t)length) { - printf(" %04X: goto \n", (unsigned)(pc - 1u)); - return 1; - } - gotoOff = (int16_t)(code[pc] | ((uint16_t)code[pc + 1u] << 8)); - target = (uint16_t)((int32_t)pc + 2 + (int32_t)gotoOff); - printf(" %04X: goto %04X\n", (unsigned)(pc - 1u), (unsigned)target); - pc = (uint16_t)(pc + 2u); - continue; - } - pc = disasmAction(code, pc, length, op); - if (op == 0x00u) { - // return -- end of logic. Keep walking past it; some - // logics have unreachable handlers below the top-level - // return that callers jump into via goto. - } - } - return 0; -} - - -// ----- main ----- - -int main(int argc, char **argv) { - AgiGameT game; - uint16_t logicId; - uint8_t *bytes; - uint16_t length; - uint16_t bytecodeLen; - uint16_t endPc; - int ret; - - if (argc < 3 || argc > 4) { - fprintf(stderr, "usage: %s [end-pc]\n", argv[0]); - return 2; - } - if (!agiResOpen(&game)) { - fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); - return 1; - } - logicId = (uint16_t)atoi(argv[2]); - if (logicId >= game.resCount[AGI_RES_LOGIC]) { - fprintf(stderr, "FAIL: no LOGIC at id %u\n", (unsigned)logicId); - agiResClose(&game); - return 1; - } - bytes = agiResLoad(&game, AGI_RES_LOGIC, logicId, &length); - if (bytes == NULL) { - fprintf(stderr, "FAIL: LOGIC %u failed to load\n", (unsigned)logicId); - agiResClose(&game); - return 1; - } - if (length < AGI_LOGIC_HDR_BYTES) { - fprintf(stderr, "FAIL: LOGIC %u truncated header\n", (unsigned)logicId); - free(bytes); - agiResClose(&game); - return 1; - } - bytecodeLen = (uint16_t)(bytes[0] | ((uint16_t)bytes[1] << 8)); - if ((uint32_t)bytecodeLen + AGI_LOGIC_HDR_BYTES > (uint32_t)length) { - fprintf(stderr, "FAIL: LOGIC %u bytecode length exceeds resource\n", (unsigned)logicId); - free(bytes); - agiResClose(&game); - return 1; - } - endPc = (argc == 4) ? (uint16_t)atoi(argv[3]) : bytecodeLen; - if (endPc > bytecodeLen) { - endPc = bytecodeLen; - } - - printf("LOGIC %u: %u bytes resource, %u bytes bytecode\n", - (unsigned)logicId, (unsigned)length, (unsigned)bytecodeLen); - ret = walk(bytes + AGI_LOGIC_HDR_BYTES, bytecodeLen, endPc); - free(bytes); - agiResClose(&game); - return ret; -} diff --git a/tests/expected/kq3/opening.txt b/tests/expected/kq3/opening.txt new file mode 100644 index 0000000..09f535e --- /dev/null +++ b/tests/expected/kq3/opening.txt @@ -0,0 +1,8 @@ +o01 fddacd89 +o02 b2742c3f +o03 74541ae3 +o04 bf373e80 +o05 bf373e80 +o06 625d901d +o07 784af2b9 +o08 6e6b8dd5 diff --git a/tests/expected/kq3/play.txt b/tests/expected/kq3/play.txt new file mode 100644 index 0000000..be2b326 --- /dev/null +++ b/tests/expected/kq3/play.txt @@ -0,0 +1,15 @@ +g01 003016a5 +g02 f5361366 +g03 9b841c62 +g04 975e862c +g05 af3ac138 +g06 2d70e908 +g07 0aa33f32 +g08 081e7951 +g09 287e4cb3 +g10 eb63dfcb +g11 8f2418db +g12 ac9a41f5 +g13 53bc8d5c +g14 82b40f94 +g15 fd1788e9 diff --git a/tests/expected/kq3/saverestore.txt b/tests/expected/kq3/saverestore.txt new file mode 100644 index 0000000..d830617 --- /dev/null +++ b/tests/expected/kq3/saverestore.txt @@ -0,0 +1,11 @@ +s01 c7fa568e +s02 6275e293 +s03 37836207 +s04 490e452f +s05 4e59a416 +s06 155d1e0e +s07 180b604f +s08 ab92c46b +s09 bb34ec9d +s10 83ac187a +s11 d6a89b3d diff --git a/tests/expected/kq3/teleport.txt b/tests/expected/kq3/teleport.txt new file mode 100644 index 0000000..25b2ecd --- /dev/null +++ b/tests/expected/kq3/teleport.txt @@ -0,0 +1,4 @@ +r20 0d79701a +r23 e2e3fed9 +r26 7c7ae16c +r29 616f1479 diff --git a/tests/expected/probes/clock.txt b/tests/expected/probes/clock.txt new file mode 100644 index 0000000..1a93d78 --- /dev/null +++ b/tests/expected/probes/clock.txt @@ -0,0 +1,4 @@ +k0 dd984418 +k1 dd984418 +k2 dd984418 +k3 656d758f diff --git a/tests/expected/probes/collide.txt b/tests/expected/probes/collide.txt new file mode 100644 index 0000000..2bce947 --- /dev/null +++ b/tests/expected/probes/collide.txt @@ -0,0 +1 @@ +a e8f6a1e9 diff --git a/tests/expected/probes/control.txt b/tests/expected/probes/control.txt new file mode 100644 index 0000000..b382b8f --- /dev/null +++ b/tests/expected/probes/control.txt @@ -0,0 +1 @@ +a 2cb92e07 diff --git a/tests/expected/probes/counters.txt b/tests/expected/probes/counters.txt new file mode 100644 index 0000000..cabc89a --- /dev/null +++ b/tests/expected/probes/counters.txt @@ -0,0 +1 @@ +a e17a096c diff --git a/tests/expected/probes/cursor.txt b/tests/expected/probes/cursor.txt new file mode 100644 index 0000000..b79a1c3 --- /dev/null +++ b/tests/expected/probes/cursor.txt @@ -0,0 +1,20 @@ +c1 edbd2e2f +c2 6c93f989 +c3 39d21a45 +c4 fcbe1f49 +c5 9a26e107 +c6 6b40fbeb +c7 e9e6dae6 +c8 6b40fbeb +c9 e913a603 +d1 d5f7bf0e +d2 c9292279 +d3 a943ceb7 +d4 bdb0338c +d5 0c21e71f +d6 c9089eb8 +d7 4efb8b66 +d8 15f8f7ca +d9 47749070 +e1 575bb647 +e2 1c028ded diff --git a/tests/expected/probes/cycling.txt b/tests/expected/probes/cycling.txt new file mode 100644 index 0000000..1148cd4 --- /dev/null +++ b/tests/expected/probes/cycling.txt @@ -0,0 +1 @@ +a 092d9b57 diff --git a/tests/expected/probes/ego.txt b/tests/expected/probes/ego.txt new file mode 100644 index 0000000..9c030ce --- /dev/null +++ b/tests/expected/probes/ego.txt @@ -0,0 +1 @@ +a 98c7cd7c diff --git a/tests/expected/probes/egodir.txt b/tests/expected/probes/egodir.txt new file mode 100644 index 0000000..a877fe7 --- /dev/null +++ b/tests/expected/probes/egodir.txt @@ -0,0 +1 @@ +a 3e55f201 diff --git a/tests/expected/probes/follow.txt b/tests/expected/probes/follow.txt new file mode 100644 index 0000000..cb30d62 --- /dev/null +++ b/tests/expected/probes/follow.txt @@ -0,0 +1 @@ +a 40833758 diff --git a/tests/expected/probes/font.txt b/tests/expected/probes/font.txt new file mode 100644 index 0000000..19c3d49 --- /dev/null +++ b/tests/expected/probes/font.txt @@ -0,0 +1 @@ +font dd6d62b9 diff --git a/tests/expected/probes/motion.txt b/tests/expected/probes/motion.txt new file mode 100644 index 0000000..b85cedf --- /dev/null +++ b/tests/expected/probes/motion.txt @@ -0,0 +1 @@ +a c110f209 diff --git a/tests/expected/probes/parser.txt b/tests/expected/probes/parser.txt new file mode 100644 index 0000000..fffdf0d --- /dev/null +++ b/tests/expected/probes/parser.txt @@ -0,0 +1,17 @@ +p01 6abc8cfa +p02 4fd5e76d +p03 f5c57fdb +p04 ed8aab2b +p05 dbe8213f +p06 ccf3d84d +p07 001f8601 +p08 b874cfa6 +p09 23b4f464 +p10 cd735a97 +m01 6abc8cfa +m02 6abc8cfa +m03 6abc8cfa +m04 6abc8cfa +m05 6abc8cfa +m06 6abc8cfa +m07 6abc8cfa diff --git a/tests/expected/probes/pictures.txt b/tests/expected/probes/pictures.txt new file mode 100644 index 0000000..1345676 --- /dev/null +++ b/tests/expected/probes/pictures.txt @@ -0,0 +1,2 @@ +visual fa0d1f87 +priority 6aa74fb7 diff --git a/tests/expected/probes/priority.txt b/tests/expected/probes/priority.txt new file mode 100644 index 0000000..79ae69d --- /dev/null +++ b/tests/expected/probes/priority.txt @@ -0,0 +1,3 @@ +a 1ac4084c +b 64ac8145 +p 87906dfd diff --git a/tests/expected/probes/redraw.txt b/tests/expected/probes/redraw.txt new file mode 100644 index 0000000..9754fc8 --- /dev/null +++ b/tests/expected/probes/redraw.txt @@ -0,0 +1 @@ +a f74cc61f diff --git a/tests/expected/probes/setview.txt b/tests/expected/probes/setview.txt new file mode 100644 index 0000000..3abd5a2 --- /dev/null +++ b/tests/expected/probes/setview.txt @@ -0,0 +1 @@ +a 123ccc90 diff --git a/tests/expected/probes/shake.txt b/tests/expected/probes/shake.txt new file mode 100644 index 0000000..232a4d8 --- /dev/null +++ b/tests/expected/probes/shake.txt @@ -0,0 +1 @@ +shake 00d23305 diff --git a/tests/expected/probes/sound.txt b/tests/expected/probes/sound.txt new file mode 100644 index 0000000..4846f3c --- /dev/null +++ b/tests/expected/probes/sound.txt @@ -0,0 +1 @@ +a 8997da11 diff --git a/tests/expected/probes/steering.txt b/tests/expected/probes/steering.txt new file mode 100644 index 0000000..bcb5997 --- /dev/null +++ b/tests/expected/probes/steering.txt @@ -0,0 +1 @@ +a f4c646c2 diff --git a/tests/expected/probes/textscreen.txt b/tests/expected/probes/textscreen.txt new file mode 100644 index 0000000..91f0739 --- /dev/null +++ b/tests/expected/probes/textscreen.txt @@ -0,0 +1,3 @@ +g1 0222bfaa +t1 a3bbcf30 +g2 0222bfaa diff --git a/tests/expected/probes/timing.txt b/tests/expected/probes/timing.txt new file mode 100644 index 0000000..79efc3f --- /dev/null +++ b/tests/expected/probes/timing.txt @@ -0,0 +1 @@ +a 4d9e1ce8 diff --git a/tests/expected/probes/windows.txt b/tests/expected/probes/windows.txt new file mode 100644 index 0000000..fb8ab50 --- /dev/null +++ b/tests/expected/probes/windows.txt @@ -0,0 +1,12 @@ +w01 cf195eef +w02 8ed9f248 +w03 d0bd77b8 +w04 35a76d64 +w05 50ffa273 +w06 0c468875 +w07 46c4e29b +w08 5574f954 +w09 2e2fac4e +w10 a5d70bc1 +w11 c05a5ca4 +w12 3bb497ec diff --git a/tests/expected/probes/winedge.txt b/tests/expected/probes/winedge.txt new file mode 100644 index 0000000..c117402 --- /dev/null +++ b/tests/expected/probes/winedge.txt @@ -0,0 +1,18 @@ +e0_open 00b962e1 +e0_closed 1f699b25 +e1_open 2c877b3d +e1_closed aa90b6d5 +e2_open 897ee79f +e2_closed d5cd02e5 +e3_open 990e2fcd +e3_closed e84d57f5 +e4_open 8f1e26b8 +e4_closed f1744bc9 +e5_open 19be3045 +e5_closed e84d57f5 +e6_open e7f7bac9 +e6_closed 295c31f1 +e7_open a6227fcd +e7_closed f48e2ce5 +e8_open 1034ed4d +e8_closed 102ecf6b diff --git a/tests/expected/resources-kq3.txt b/tests/expected/resources-kq3.txt new file mode 100644 index 0000000..c0993cd --- /dev/null +++ b/tests/expected/resources-kq3.txt @@ -0,0 +1,473 @@ +interpreter 2435 +logic 0 code 6778 messages 144 +view 0 loops 4 cels 28 pixels 0f681f64 +logic 1 code 895 messages 27 +picture 1 visual 329e9052 priority 7c218c8f +view 1 loops 4 cels 16 pixels cd1bd77d +sound 1 notes 51 9 48 5 +logic 2 code 2488 messages 42 +picture 2 visual 4ad3a933 priority 1a05f607 +view 2 loops 4 cels 20 pixels bd49a3c6 +sound 2 notes 59 74 1 1 +logic 3 code 443 messages 8 +picture 3 visual d42e7f3a priority b004a114 +view 3 loops 4 cels 28 pixels bb849240 +sound 3 notes 8 1 1 2 +logic 4 code 3782 messages 38 +picture 4 visual 929dd5c7 priority 317d182b +view 4 loops 1 cels 10 pixels 46049c68 +sound 4 notes 10 1 1 4 +logic 5 code 2336 messages 36 +picture 5 visual 72cb08ea priority 9498253b +view 5 loops 1 cels 1 pixels 38dada04 +sound 5 notes 14 14 1 1 +logic 6 code 2122 messages 63 +picture 6 visual 05336461 priority 75113374 +view 6 loops 1 cels 4 pixels 52fdd842 +sound 6 notes 4 6 7 1 +logic 7 code 915 messages 11 +picture 7 visual 9146dd38 priority c9fa84a5 +view 7 loops 4 cels 28 pixels 0dee3f03 +sound 7 notes 10 1 1 4 +logic 8 code 3164 messages 36 +picture 8 visual 7f3449b2 priority 3ea16cf2 +view 8 loops 2 cels 18 pixels af423bc5 +sound 8 notes 17 19 1 1 +logic 9 code 432 messages 8 +picture 9 visual 36203a15 priority 97584b96 +view 9 loops 1 cels 7 pixels 93bb2aa1 +sound 9 notes 1 1 1 21 +logic 10 code 3837 messages 72 +picture 10 visual f7541608 priority e56e5c16 +view 10 loops 4 cels 24 pixels 71961597 +sound 10 notes 10 1 1 4 +logic 11 code 318 messages 11 +picture 11 visual 39b4474d priority 955293d1 +view 11 loops 1 cels 8 pixels 3707aacf +sound 11 notes 4 1 1 4 +logic 12 code 185 messages 0 +picture 12 visual d3cff1df priority 331ca9c5 +view 12 loops 2 cels 14 pixels b60f62dd +sound 12 notes 13 13 1 1 +logic 13 code 710 messages 5 +picture 13 visual ac5ca216 priority 795b5a59 +view 13 loops 1 cels 9 pixels 81b43ddc +sound 13 notes 12 1 1 1 +logic 14 code 1334 messages 16 +picture 14 visual e41998f2 priority 3d6efd35 +view 14 loops 1 cels 10 pixels 2568ef4a +sound 14 notes 6 1 1 6 +logic 15 code 413 messages 0 +picture 15 visual e063ac9d priority dc9b5348 +view 15 loops 1 cels 3 pixels 98d9e7c4 +sound 15 notes 6 1 1 4 +logic 16 code 139 messages 0 +picture 16 visual 9199ea13 priority 2b073fe7 +view 16 loops 2 cels 9 pixels 3a288ff4 +logic 17 code 252 messages 0 +picture 17 visual 247be7cb priority 05368fd5 +view 17 loops 2 cels 10 pixels 1436da12 +logic 18 code 552 messages 5 +picture 18 visual 66ffd417 priority e1470d06 +view 18 loops 1 cels 6 pixels a0d463e2 +logic 19 code 320 messages 3 +picture 19 visual 1fd4b8d2 priority c6f4d7ee +view 19 loops 1 cels 8 pixels c0be60de +logic 20 code 695 messages 3 +picture 20 visual 28f3414c priority 1d9f0394 +view 20 loops 4 cels 28 pixels 65ec367c +logic 21 code 350 messages 4 +picture 21 visual 13716309 priority a639bc3a +view 21 loops 3 cels 10 pixels e4d55e4f +sound 21 notes 62 30 30 0 +logic 22 code 1485 messages 19 +picture 22 visual a688ed69 priority 371ba442 +sound 22 notes 32 36 30 0 +logic 23 code 312 messages 5 +picture 23 visual 841169dd priority a434a281 +view 23 loops 1 cels 10 pixels f112f931 +sound 23 notes 67 45 95 0 +logic 24 code 1312 messages 18 +picture 24 visual 805ce07b priority 42437aa7 +view 24 loops 1 cels 1 pixels 4550a884 +sound 24 notes 121 142 115 0 +logic 25 code 966 messages 6 +picture 25 visual da6aa39c priority fd9bd8a4 +view 25 loops 1 cels 10 pixels 9dba8865 +sound 25 notes 18 15 11 0 +logic 26 code 145 messages 0 +picture 26 visual d4600f5b priority 116d8fee +view 26 loops 2 cels 14 pixels 8056da53 +sound 26 notes 10 9 10 0 +logic 27 code 216 messages 0 +picture 27 visual 24bba7b1 priority 3114b1cf +view 27 loops 4 cels 8 pixels 1695f82d +sound 27 notes 10 8 8 0 +logic 28 code 2406 messages 40 +picture 28 visual 212f56b9 priority 442dc510 +view 28 loops 4 cels 28 pixels 02fe61cf +sound 28 notes 55 68 50 0 +logic 29 code 858 messages 17 +picture 29 visual 10be702f priority 6fb1a208 +view 29 loops 4 cels 25 pixels 373f70db +sound 29 notes 54 55 28 0 +logic 30 code 602 messages 4 +picture 30 visual f22f602a priority a82217f4 +view 30 loops 2 cels 16 pixels 6b7de209 +sound 30 notes 102 42 54 0 +logic 31 code 1239 messages 22 +picture 31 visual 74d532c5 priority 34fcede5 +view 31 loops 2 cels 8 pixels d6a5bd5d +sound 31 notes 41 0 0 0 +logic 32 code 697 messages 12 +picture 32 visual c8383685 priority 2e20d5c5 +view 32 loops 2 cels 8 pixels fa83658d +sound 32 notes 12 12 12 0 +logic 33 code 682 messages 6 +picture 33 visual 7fd6392f priority a70ba4b7 +view 33 loops 2 cels 14 pixels 095ffbf5 +sound 33 notes 66 44 42 0 +logic 34 code 2197 messages 43 +picture 34 visual ebe094d2 priority 2b57ea18 +view 34 loops 1 cels 9 pixels 48e09f12 +sound 34 notes 69 32 32 0 +logic 35 code 94 messages 6 +picture 35 visual 486e3be6 priority 8649ad2f +view 35 loops 1 cels 4 pixels 39d3b961 +sound 35 notes 15 26 26 0 +logic 36 code 739 messages 18 +picture 36 visual fc3945a3 priority 071f283d +view 36 loops 4 cels 12 pixels 9e3f127e +sound 36 notes 151 153 149 0 +logic 37 code 661 messages 13 +picture 37 visual 3c19d37c priority a763d473 +view 37 loops 4 cels 20 pixels 09eb3089 +sound 37 notes 25 4 4 0 +logic 38 code 1099 messages 24 +picture 38 visual 813dd175 priority 1618c2ed +view 38 loops 4 cels 17 pixels 7c0352fb +sound 38 notes 17 0 0 0 +logic 39 code 1366 messages 51 +picture 39 visual 33dd4b05 priority faa88819 +view 39 loops 3 cels 15 pixels 6088ef6b +sound 39 notes 99 57 58 0 +logic 40 code 1826 messages 54 +picture 40 visual 29219083 priority 0612fd59 +view 40 loops 4 cels 24 pixels 93437817 +logic 41 code 1310 messages 36 +picture 41 visual ff908fac priority 1dd84d29 +view 41 loops 2 cels 12 pixels cc45b4b1 +logic 42 code 1208 messages 29 +picture 42 visual d4cfe5ba priority 955c5346 +view 42 loops 2 cels 16 pixels de8554ef +logic 43 code 602 messages 18 +picture 43 visual d73a6f45 priority 2e20d5c5 +view 43 loops 2 cels 18 pixels 7fa345dd +view 44 loops 2 cels 19 pixels a228f386 +logic 45 code 1759 messages 42 +picture 45 visual 0ff62c5f priority 2ef2c44e +view 45 loops 4 cels 39 pixels 063e3f54 +logic 46 code 526 messages 5 +picture 46 visual 0e924d07 priority 9589e1ce +view 46 loops 3 cels 14 pixels 872cfd84 +picture 47 visual 6b7a8375 priority 2e20d5c5 +view 47 loops 2 cels 12 pixels bd1835db +logic 48 code 601 messages 11 +picture 48 visual a4a35a34 priority e9a54109 +view 48 loops 3 cels 14 pixels 0cf25c53 +logic 49 code 802 messages 16 +picture 49 visual 95112fa7 priority 6002128f +view 49 loops 1 cels 4 pixels aec33377 +logic 50 code 425 messages 7 +picture 50 visual f2adf287 priority d1bc6197 +logic 51 code 547 messages 7 +picture 51 visual 24222e55 priority 5f4eddc6 +view 51 loops 4 cels 24 pixels bc097266 +logic 52 code 432 messages 7 +picture 52 visual cdad2aa1 priority a5b7e996 +view 52 loops 4 cels 36 pixels 432ab78b +logic 53 code 671 messages 12 +picture 53 visual efa174b8 priority af27e66d +logic 54 code 636 messages 11 +picture 54 visual e803b77d priority bf6eb7f6 +view 54 loops 1 cels 12 pixels 85a73127 +logic 55 code 536 messages 13 +picture 55 visual 232fde88 priority 24331781 +logic 56 code 571 messages 17 +picture 56 visual 5d93290d priority f293db2f +view 56 loops 4 cels 17 pixels 55998cc7 +logic 57 code 999 messages 25 +picture 57 visual 6035cafa priority 34391afb +view 57 loops 1 cels 8 pixels 24571dcf +logic 58 code 349 messages 7 +picture 58 visual 93f9bfb2 priority 6aec7c73 +view 58 loops 2 cels 8 pixels 2ea41665 +logic 59 code 319 messages 7 +picture 59 visual 6b9d445d priority 8a30ead0 +view 59 loops 2 cels 10 pixels 8c3dc255 +logic 60 code 417 messages 17 +picture 60 visual beb70e28 priority 0adf81fe +view 60 loops 3 cels 3 pixels 69567fb6 +logic 61 code 582 messages 12 +picture 61 visual af233d3e priority c7c19e7f +view 61 loops 3 cels 3 pixels d16e78ab +logic 62 code 277 messages 5 +picture 62 visual be854141 priority e99a9740 +view 62 loops 4 cels 10 pixels 751e9a0e +logic 63 code 254 messages 5 +picture 63 visual 22995dfc priority f5f2f366 +view 63 loops 1 cels 1 pixels 47387319 +logic 64 code 312 messages 5 +picture 64 visual 9947b0f5 priority 7457e6cb +view 64 loops 1 cels 1 pixels de156123 +logic 65 code 247 messages 7 +picture 65 visual 298f1769 priority 388124ef +view 65 loops 2 cels 10 pixels a5ee8429 +logic 66 code 1390 messages 24 +picture 66 visual 49ec136f priority 283923d7 +view 66 loops 1 cels 4 pixels 061e86e7 +logic 67 code 327 messages 12 +picture 67 visual 27cc7783 priority 3979bf85 +view 67 loops 1 cels 7 pixels 5b3201f5 +logic 68 code 437 messages 12 +picture 68 visual 5ada2325 priority d53d5088 +view 68 loops 1 cels 2 pixels af4f991d +logic 69 code 623 messages 25 +picture 69 visual 26b880f3 priority cd27f03a +view 69 loops 4 cels 18 pixels bc5a6a50 +logic 70 code 226 messages 2 +picture 70 visual 9a51c920 priority bc2c5485 +view 70 loops 4 cels 15 pixels ee55e881 +logic 71 code 556 messages 18 +picture 71 visual 4b18a836 priority e5f0b48b +view 71 loops 2 cels 16 pixels 858cb785 +logic 72 code 306 messages 18 +picture 72 visual 43a78d32 priority 5e55fbf2 +view 72 loops 2 cels 8 pixels 51b5e48d +logic 73 code 188 messages 3 +picture 73 visual 68f1a471 priority 19f9fb51 +view 73 loops 4 cels 16 pixels 345bfc51 +logic 74 code 915 messages 16 +picture 74 visual da80a511 priority 55b69b73 +view 74 loops 1 cels 8 pixels 204192c8 +logic 75 code 1465 messages 27 +picture 75 visual 32e469aa priority a5031158 +view 75 loops 1 cels 4 pixels db72bcb4 +logic 76 code 683 messages 6 +picture 76 visual b77a72a8 priority 67e4e005 +view 76 loops 4 cels 20 pixels ac6a428f +logic 77 code 154 messages 2 +view 77 loops 2 cels 4 pixels ab947e32 +logic 78 code 853 messages 15 +picture 78 visual 9e85475d priority 351609c1 +view 78 loops 4 cels 19 pixels 8a37240a +logic 79 code 443 messages 12 +picture 79 visual ddbf5114 priority b398034b +view 79 loops 1 cels 4 pixels 47e418f2 +logic 80 code 992 messages 13 +picture 80 visual 500553dc priority 7bcd1510 +view 80 loops 4 cels 20 pixels 0493437d +logic 81 code 908 messages 13 +picture 81 visual 63ce560e priority 8bcb477a +logic 82 code 707 messages 10 +picture 82 visual 08b24a4f priority 661a2af3 +view 82 loops 2 cels 12 pixels e9e9d68d +logic 83 code 1683 messages 27 +picture 83 visual 12345f5e priority 953b713c +view 83 loops 2 cels 6 pixels 66e3fb3f +logic 84 code 778 messages 18 +picture 84 visual 30c39096 priority f1f01db0 +view 84 loops 2 cels 16 pixels 6da07225 +logic 85 code 561 messages 11 +picture 85 visual efd9fe0a priority a0f1d19a +view 85 loops 1 cels 3 pixels c38ddf34 +logic 86 code 478 messages 26 +picture 86 visual c2b99d27 priority bb68a175 +view 86 loops 1 cels 4 pixels 90657743 +logic 87 code 192 messages 2 +picture 87 visual 9aaa9785 priority 54680636 +picture 88 visual 558d9555 priority 28e20f45 +view 88 loops 1 cels 4 pixels d12f33bb +logic 89 code 1834 messages 8 +view 90 loops 1 cels 4 pixels b22ec0c1 +logic 92 code 137 messages 29 +view 92 loops 1 cels 4 pixels 45b62afb +logic 93 code 40 messages 1 +view 93 loops 1 cels 5 pixels d56c88b1 +logic 94 code 157 messages 1 +view 94 loops 2 cels 10 pixels 1f444d33 +logic 95 code 67 messages 1 +view 95 loops 1 cels 4 pixels 5c540485 +logic 96 code 1 messages 178 +view 96 loops 2 cels 11 pixels e5826d31 +logic 97 code 82 messages 1 +view 97 loops 1 cels 3 pixels 24b8bf18 +logic 98 code 5 messages 32 +view 98 loops 1 cels 4 pixels e876b3e5 +logic 99 code 748 messages 33 +logic 100 code 187 messages 0 +logic 101 code 272 messages 3 +view 101 loops 1 cels 1 pixels 72f6fdfe +logic 102 code 81 messages 15 +view 102 loops 1 cels 1 pixels 4e2c487e +logic 103 code 417 messages 4 +view 103 loops 1 cels 1 pixels 7aeff556 +logic 104 code 810 messages 27 +view 104 loops 1 cels 1 pixels 2373d0ab +logic 105 code 922 messages 25 +view 105 loops 1 cels 1 pixels 42d6dee1 +logic 106 code 1107 messages 29 +view 106 loops 1 cels 1 pixels 5b4ecc1a +logic 107 code 277 messages 0 +view 107 loops 1 cels 1 pixels 187679e9 +logic 108 code 533 messages 9 +view 108 loops 1 cels 1 pixels 080c61d8 +logic 109 code 1004 messages 19 +view 109 loops 1 cels 1 pixels 456223a4 +logic 110 code 299 messages 12 +view 110 loops 1 cels 1 pixels d8c808d2 +logic 111 code 1000 messages 44 +view 111 loops 1 cels 1 pixels da310b48 +logic 112 code 285 messages 2 +view 112 loops 1 cels 1 pixels a6a4324f +logic 113 code 106 messages 1 +view 113 loops 1 cels 1 pixels 9601e9be +logic 114 code 443 messages 8 +view 114 loops 1 cels 1 pixels e217a2d9 +logic 115 code 844 messages 10 +view 115 loops 1 cels 1 pixels fad25d04 +logic 116 code 42 messages 3 +view 116 loops 1 cels 1 pixels ff79e3d1 +logic 117 code 549 messages 12 +view 117 loops 1 cels 1 pixels adf8caa1 +logic 118 code 674 messages 17 +view 118 loops 1 cels 1 pixels be7457f7 +logic 119 code 395 messages 21 +view 119 loops 1 cels 2 pixels 8a67c378 +logic 120 code 318 messages 12 +view 120 loops 1 cels 1 pixels 299ea209 +logic 121 code 1164 messages 29 +view 121 loops 1 cels 1 pixels 828fc6ec +logic 122 code 429 messages 10 +picture 122 visual 89455a65 priority a4351d16 +view 122 loops 1 cels 1 pixels 60dc5f7f +logic 123 code 602 messages 17 +view 123 loops 1 cels 1 pixels ce05e564 +logic 124 code 946 messages 26 +view 124 loops 1 cels 1 pixels 775be74b +logic 125 code 633 messages 15 +picture 125 visual 3530172d priority 662cd747 +view 125 loops 1 cels 1 pixels 775be74b +logic 126 code 1030 messages 16 +view 126 loops 1 cels 1 pixels b500bf35 +logic 127 code 856 messages 18 +view 127 loops 1 cels 1 pixels a546227c +logic 128 code 657 messages 24 +view 128 loops 1 cels 1 pixels 3375b3cf +logic 129 code 131 messages 11 +view 129 loops 1 cels 1 pixels 514249b1 +view 130 loops 1 cels 2 pixels ee35a0fa +picture 131 visual 3b883fc9 priority 090b0331 +view 131 loops 1 cels 1 pixels ed9a66a8 +picture 132 visual 4e9bfd15 priority 2e20d5c5 +view 132 loops 1 cels 2 pixels 3ef657d2 +picture 133 visual 79cd2ea2 priority 090b0331 +view 133 loops 1 cels 1 pixels 8de948a4 +picture 134 visual bc63ce47 priority 090b0331 +view 134 loops 1 cels 2 pixels 231a12b4 +view 136 loops 1 cels 2 pixels 1f21ce54 +view 137 loops 1 cels 2 pixels 83b667f0 +view 138 loops 1 cels 2 pixels 24d17d54 +view 139 loops 1 cels 1 pixels a6a4324f +view 140 loops 1 cels 1 pixels c10b9d0a +view 141 loops 1 cels 1 pixels cae29458 +view 142 loops 1 cels 1 pixels 911979d4 +picture 143 visual 24a5937f priority 2e20d5c5 +view 143 loops 1 cels 1 pixels 105686f7 +view 147 loops 1 cels 2 pixels 5997882e +picture 148 visual 62b0b8dc priority 102c7c76 +view 148 loops 1 cels 2 pixels 09a4e7e6 +view 149 loops 1 cels 1 pixels 4c905dd3 +view 150 loops 1 cels 2 pixels fa954041 +view 151 loops 1 cels 2 pixels 56c14092 +view 152 loops 1 cels 2 pixels 247b403b +view 153 loops 1 cels 2 pixels 9629a73a +view 155 loops 1 cels 7 pixels cb9826f5 +view 156 loops 2 cels 12 pixels c3cc9229 +view 157 loops 2 cels 12 pixels d4e2a085 +view 158 loops 2 cels 16 pixels 2cbe9fa5 +view 159 loops 2 cels 10 pixels d3b4fc85 +view 160 loops 1 cels 4 pixels ae76ea18 +view 161 loops 2 cels 16 pixels 2b2bd9a7 +view 162 loops 1 cels 9 pixels 0340ba7f +view 163 loops 2 cels 12 pixels 68edcd2d +view 164 loops 3 cels 11 pixels f7006733 +view 165 loops 1 cels 6 pixels 0ad94d18 +picture 166 visual 586fa489 priority 5a8a037b +view 166 loops 3 cels 14 pixels f2b024e3 +view 167 loops 1 cels 2 pixels 327251f7 +view 168 loops 1 cels 1 pixels 5c464f93 +view 169 loops 1 cels 1 pixels e5ca925f +view 170 loops 3 cels 4 pixels 6e64c617 +view 171 loops 4 cels 14 pixels 024958f9 +view 172 loops 2 cels 6 pixels 986ecd7f +view 173 loops 2 cels 14 pixels 3b6d535b +view 174 loops 1 cels 1 pixels 060bc2ef +picture 175 visual 247f168a priority ed392918 +view 175 loops 1 cels 4 pixels 8b3c2393 +picture 176 visual b5b9f822 priority ea6867a1 +view 176 loops 1 cels 1 pixels d1532cee +view 177 loops 1 cels 1 pixels 4a955462 +view 178 loops 1 cels 4 pixels cd8f29cd +view 179 loops 1 cels 4 pixels 80913381 +view 180 loops 1 cels 4 pixels 41c7d7e4 +view 182 loops 1 cels 4 pixels 7d2651eb +view 185 loops 1 cels 5 pixels 57b0732b +view 186 loops 2 cels 14 pixels 466c2ca7 +view 187 loops 3 cels 21 pixels d309c2aa +view 190 loops 4 cels 8 pixels 6e8fe275 +view 191 loops 1 cels 2 pixels 72cbb584 +view 192 loops 1 cels 10 pixels 3e2b5ee1 +view 193 loops 2 cels 10 pixels 63ed6b4f +view 194 loops 1 cels 7 pixels 508c952c +view 195 loops 1 cels 2 pixels 82f7e047 +view 196 loops 1 cels 10 pixels c4086043 +view 197 loops 1 cels 10 pixels 2e12ab7e +view 199 loops 1 cels 9 pixels fa90f4d0 +view 200 loops 1 cels 1 pixels 11295cc5 +view 201 loops 1 cels 1 pixels 4bcd4dd2 +view 203 loops 4 cels 16 pixels 8ef700c1 +view 204 loops 2 cels 16 pixels 013880d9 +view 205 loops 2 cels 8 pixels 5405b91d +view 206 loops 2 cels 16 pixels 7e017ab9 +view 207 loops 1 cels 1 pixels 22940efd +view 208 loops 1 cels 8 pixels 9dc7946a +view 209 loops 2 cels 19 pixels 279d312c +view 210 loops 4 cels 24 pixels f4b5514d +view 211 loops 4 cels 24 pixels 3766f515 +view 214 loops 1 cels 2 pixels 20be3280 +view 216 loops 1 cels 1 pixels fc50c3e3 +view 217 loops 1 cels 5 pixels 9d7e80de +view 220 loops 2 cels 2 pixels 6fd93d2b +view 221 loops 3 cels 10 pixels 76ccbee8 +view 222 loops 1 cels 3 pixels e5276203 +view 223 loops 1 cels 3 pixels 676f5aee +view 225 loops 4 cels 19 pixels 9de7ebda +view 226 loops 1 cels 3 pixels ecd03070 +view 227 loops 1 cels 3 pixels 2ef18662 +view 229 loops 1 cels 5 pixels 65abca39 +view 230 loops 1 cels 3 pixels e0d19e69 +view 231 loops 1 cels 6 pixels 343af315 +view 232 loops 4 cels 17 pixels 9d7e8581 +view 236 loops 2 cels 4 pixels f7081d15 +view 240 loops 4 cels 16 pixels 2e91cc99 +view 241 loops 1 cels 9 pixels 3b3fa27e +view 242 loops 1 cels 1 pixels 591d9bf1 +view 243 loops 1 cels 5 pixels 78eb7fc9 +view 244 loops 1 cels 3 pixels 53485ecd +view 245 loops 1 cels 3 pixels 47368ac5 +view 246 loops 1 cels 1 pixels 38e43d15 +view 248 loops 2 cels 11 pixels ae2412bc +view 249 loops 2 cels 10 pixels bc310549 +view 250 loops 2 cels 12 pixels 7949f929 +view 251 loops 1 cels 10 pixels 7f960f3b diff --git a/tests/hostPort.c b/tests/hostPort.c new file mode 100644 index 0000000..1cb2d06 --- /dev/null +++ b/tests/hostPort.c @@ -0,0 +1,462 @@ +// Headless host port of JoeyLib for running the whole interpreter on the +// build machine: JoeyLib's BLANK platform with these services filled in. +// +// Time is virtual. Every jlWaitVBL is one 1/60 s frame, so a run is exactly +// reproducible: the same script gives the same frames, independent of how +// fast the host is. The display is the stage itself, resolved through each +// row's palette at every present. +// +// The input script is the one scripts/dosDrive.py feeds DOSBox, so the same +// file drives Sierra's interpreter, JoeyAGI in DOSBox and this harness: +// 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 +// type TEXT press each character's key in turn, typing it +// shot NAME write the displayed frame to OUTDIR/NAME.ppm +// clear press Return until no message window is open +// record NAME note every frame from here ... +// stop ... to here: OUTDIR/NAME.frames lists each +// frame's hash and OUTDIR/NAME-HASH.ppm holds each +// different frame once +// '#' starts a comment. When the script ends the run stops. +// +// Every shot prints its frame's FNV-1a hash (the gate compares those) and, +// unless the output folder is "-", writes a PPM; a recording prints one +// hash for its whole sequence of frames, as a shot named after it. + +#include "hostPort.h" + +#include "port.h" +#include "surfaceInternal.h" +#include "inputInternal.h" +#include "agi.h" + +#include +#include +#include + + +#define HOST_FRAME_HZ 60u +#define HOST_LINE_MAX 512 +#define HOST_PATH_MAX 1024 +// A file path: the output folder, a name from the script and a suffix. +#define HOST_FILE_MAX (HOST_PATH_MAX + HOST_LINE_MAX + 64) +#define HOST_KEY_HOLD_FRAMES 7u // 0.12 s, dosDrive.py's default press +#define HOST_KEY_GAP_FRAMES 5u // 0.08 s, dosDrive.py's gap after a press +#define HOST_CHANNEL_MAX 255u +#define HOST_NIBBLE_SCALE 17u // $0RGB nibble -> 8-bit channel +#define HOST_PIXELS_PER_BYTE 2u +#define HOST_CLEAR_GAP_FRAMES 36u // 0.6 s, dosDrive.py's pause between Returns +#define HOST_CLEAR_TRIES 8u // dosDrive.py's limit on Returns per clear +#define HOST_NO_FILES "-" +#define FNV_OFFSET_BASIS 2166136261u +#define FNV_PRIME 16777619u +#define HASH_BYTES 4u +#define BYTE_BITS 8u +#define BYTE_MASK 0xFFu +#define RECORD_DISTINCT_MAX 64u + + +typedef struct { + const char *name; + jlKeyE key; + uint8_t ch; +} HostKeyNameT; + + +// ----- Prototypes ----- + +static void hostDump(const char *name); +static uint32_t hostFrameHash(void); +static bool hostKeyLookup(const char *name, jlKeyE *outKey, uint8_t *outCh); +static void hostPress(jlKeyE key, uint8_t ch, uint32_t holdFrames); +static void hostRecordFrame(void); +static void hostStep(void); +static void hostWritePpm(const char *path); + + +// ----- Module state ----- + +static const HostKeyNameT kKeyNames[] = { + { "Return", KEY_RETURN, JL_CHAR_RETURN }, + { "Escape", KEY_ESCAPE, JL_CHAR_ESCAPE }, + { "Tab", KEY_TAB, JL_CHAR_TAB }, + { "BackSpace", KEY_BACKSPACE, JL_CHAR_BACKSPACE }, + { "space", KEY_SPACE, ' ' }, + { "Up", KEY_UP, 0u }, + { "Down", KEY_DOWN, 0u }, + { "Left", KEY_LEFT, 0u }, + { "Right", KEY_RIGHT, 0u }, + { "F1", KEY_F1, 0u }, + { "F2", KEY_F2, 0u }, + { "F3", KEY_F3, 0u }, + { "F4", KEY_F4, 0u }, + { "F5", KEY_F5, 0u }, + { "F6", KEY_F6, 0u }, + { "F7", KEY_F7, 0u }, + { "F8", KEY_F8, 0u }, + { "F9", KEY_F9, 0u }, + { "F10", KEY_F10, 0u }, + { "ctrl", KEY_LCTRL, 0u }, + { "alt", KEY_LALT, 0u }, + { "shift", KEY_LSHIFT, 0u }, + { "period", KEY_NONE, '.' }, + { "comma", KEY_NONE, ',' }, + { "apostrophe", KEY_NONE, '\'' }, + { "minus", KEY_NONE, '-' }, + { "question", KEY_NONE, '?' }, + { "exclam", KEY_NONE, '!' } +}; + +static FILE *gScript; +static char gOutDir[HOST_PATH_MAX]; +static uint32_t gFrame; +static uint32_t gResumeFrame; +static uint32_t gHoldUntil[KEY_COUNT]; +static bool gHeld[KEY_COUNT]; +static uint8_t gDisplay[SURFACE_HEIGHT][SURFACE_WIDTH][3]; +// Characters still to type for the current 'type' line, one per press. +static char gTypeBuf[HOST_LINE_MAX]; +static long gLinePos; +static uint16_t gTypePos; +static uint8_t gClearTries; +// A recording: its name, its frame list, the hash of its frame sequence +// and the frames written so far. +static char gRecordName[HOST_LINE_MAX]; +static FILE *gRecordLog; +static bool gRecording; +static uint32_t gRecordHash; +static uint32_t gRecordFrames; +static uint32_t gRecordSeen[RECORD_DISTINCT_MAX]; +static uint8_t gRecordSeenCount; + + +// ----- Internal helpers (alphabetical) ----- + +static void hostDump(const char *name) { + char path[HOST_FILE_MAX]; + + printf("[%6.1f] shot %s %08x\n", (double)gFrame / (double)HOST_FRAME_HZ, name, (unsigned)hostFrameHash()); + if (strcmp(gOutDir, HOST_NO_FILES) == 0) { + return; + } + snprintf(path, sizeof(path), "%s/%s.ppm", gOutDir, name); + hostWritePpm(path); +} + + +static uint32_t hostFrameHash(void) { + const uint8_t *p; + uint32_t hash; + size_t i; + + hash = FNV_OFFSET_BASIS; + p = &gDisplay[0][0][0]; + for (i = 0; i < sizeof(gDisplay); i++) { + hash = (hash ^ p[i]) * FNV_PRIME; + } + return hash; +} + + +// Map an xdotool key name (or a single character) to a JoeyLib key and +// the character it types. +static bool hostKeyLookup(const char *name, jlKeyE *outKey, uint8_t *outCh) { + size_t i; + char c; + + for (i = 0; i < sizeof(kKeyNames) / sizeof(kKeyNames[0]); i++) { + if (strcmp(kKeyNames[i].name, name) == 0) { + *outKey = kKeyNames[i].key; + *outCh = kKeyNames[i].ch; + return true; + } + } + if (name[0] == '\0' || name[1] != '\0') { + return false; + } + c = name[0]; + if (c >= 'a' && c <= 'z') { + *outKey = (jlKeyE)(KEY_A + (c - 'a')); + *outCh = (uint8_t)c; + return true; + } + if (c >= 'A' && c <= 'Z') { + *outKey = (jlKeyE)(KEY_A + (c - 'A')); + *outCh = (uint8_t)c; + return true; + } + if (c >= '0' && c <= '9') { + *outKey = (jlKeyE)(KEY_0 + (c - '0')); + *outCh = (uint8_t)c; + return true; + } + *outKey = KEY_NONE; + *outCh = (uint8_t)c; + return true; +} + + +static void hostPress(jlKeyE key, uint8_t ch, uint32_t holdFrames) { + if (key != KEY_NONE) { + gHoldUntil[key] = gFrame + holdFrames; + } + // Like the real keyboards, a Ctrl or Alt chord types no character. + if (ch != 0u && !gHeld[KEY_LALT] && !gHeld[KEY_LCTRL]) { + jlInputCharPush(ch); + } + gResumeFrame = gFrame + holdFrames + HOST_KEY_GAP_FRAMES; +} + + +// One frame of a recording: into the sequence hash and the frame list, +// and written out the first time it is seen. +static void hostRecordFrame(void) { + char path[HOST_FILE_MAX]; + uint32_t hash; + uint8_t i; + + hash = hostFrameHash(); + for (i = 0u; i < HASH_BYTES; i++) { + gRecordHash = (gRecordHash ^ ((hash >> (i * BYTE_BITS)) & BYTE_MASK)) * FNV_PRIME; + } + gRecordFrames++; + if (gRecordLog == NULL) { + return; + } + fprintf(gRecordLog, "%u %08x\n", (unsigned)gRecordFrames, (unsigned)hash); + for (i = 0u; i < gRecordSeenCount; i++) { + if (gRecordSeen[i] == hash) { + return; + } + } + if (gRecordSeenCount >= RECORD_DISTINCT_MAX) { + return; + } + gRecordSeen[gRecordSeenCount++] = hash; + snprintf(path, sizeof(path), "%s/%s-%08x.ppm", gOutDir, gRecordName, (unsigned)hash); + hostWritePpm(path); +} + + +// Run script lines until one asks for time to pass. +static void hostStep(void) { + char line[HOST_LINE_MAX]; + char *op; + char *arg; + jlKeyE key; + uint8_t ch; + double secs; + + while (gScript != NULL && gFrame >= gResumeFrame) { + if (gTypeBuf[gTypePos] != '\0') { + char one[2]; + + one[0] = gTypeBuf[gTypePos]; + one[1] = '\0'; + gTypePos++; + if (one[0] == ' ') { + hostPress(KEY_SPACE, ' ', HOST_KEY_HOLD_FRAMES); + } else if (hostKeyLookup(one, &key, &ch)) { + hostPress(key, ch, HOST_KEY_HOLD_FRAMES); + } + continue; + } + gLinePos = ftell(gScript); + if (fgets(line, sizeof(line), gScript) == NULL) { + printf("[%6.1f] script done\n", (double)gFrame / (double)HOST_FRAME_HZ); + exit(0); + } + line[strcspn(line, "\r\n")] = '\0'; + op = line; + while (*op == ' ' || *op == '\t') { + op++; + } + if (strncmp(op, "type ", 5) == 0) { + strncpy(gTypeBuf, op + 5, sizeof(gTypeBuf) - 1u); + gTypeBuf[sizeof(gTypeBuf) - 1u] = '\0'; + gTypePos = 0u; + continue; + } + op[strcspn(op, "#")] = '\0'; + arg = op + strcspn(op, " \t"); + if (*arg != '\0') { + *arg = '\0'; + arg++; + while (*arg == ' ' || *arg == '\t') { + arg++; + } + } + if (*op == '\0') { + continue; + } + if (strcmp(op, "wait") == 0) { + secs = atof(arg); + gResumeFrame = gFrame + (uint32_t)(secs * HOST_FRAME_HZ + 0.5); + } else if (strcmp(op, "key") == 0) { + char *holdArg; + double hold; + + holdArg = arg + strcspn(arg, " \t"); + hold = 0.0; + if (*holdArg != '\0') { + *holdArg = '\0'; + hold = atof(holdArg + 1); + } + if (!hostKeyLookup(arg, &key, &ch)) { + fprintf(stderr, "agiHost: unknown key '%s'\n", arg); + exit(2); + } + hostPress(key, ch, (hold > 0.0) ? (uint32_t)(hold * HOST_FRAME_HZ + 0.5) : HOST_KEY_HOLD_FRAMES); + } else if (strcmp(op, "down") == 0 || strcmp(op, "up") == 0) { + if (!hostKeyLookup(arg, &key, &ch) || key == KEY_NONE) { + fprintf(stderr, "agiHost: unknown key '%s'\n", arg); + exit(2); + } + gHeld[key] = (op[0] == 'd'); + if (gHeld[key] && ch != 0u) { + jlInputCharPush(ch); + } + } else if (strcmp(op, "shot") == 0) { + hostDump(arg); + } else if (strcmp(op, "record") == 0) { + char path[HOST_FILE_MAX]; + + strncpy(gRecordName, arg, sizeof(gRecordName) - 1u); + gRecording = true; + gRecordHash = FNV_OFFSET_BASIS; + gRecordFrames = 0u; + gRecordSeenCount = 0u; + if (strcmp(gOutDir, HOST_NO_FILES) != 0) { + snprintf(path, sizeof(path), "%s/%s.frames", gOutDir, gRecordName); + gRecordLog = fopen(path, "w"); + } + } else if (strcmp(op, "stop") == 0) { + gRecording = false; + if (gRecordLog != NULL) { + fclose(gRecordLog); + gRecordLog = NULL; + } + printf("[%6.1f] shot %s %08x\n", (double)gFrame / (double)HOST_FRAME_HZ, gRecordName, (unsigned)gRecordHash); + } else if (strcmp(op, "clear") == 0) { + // Dismiss message windows: Return while one is open, a few + // times at most (a window that wants another key stays). + if (gAgi != NULL && gAgi->windowOpen && gClearTries < HOST_CLEAR_TRIES) { + gClearTries++; + hostPress(KEY_RETURN, JL_CHAR_RETURN, HOST_KEY_HOLD_FRAMES); + gResumeFrame = gFrame + HOST_CLEAR_GAP_FRAMES; + fseek(gScript, gLinePos, SEEK_SET); + } else { + gClearTries = 0u; + } + } else { + fprintf(stderr, "agiHost: bad script op '%s'\n", op); + exit(2); + } + } +} + + +static void hostWritePpm(const char *path) { + FILE *fp; + + fp = fopen(path, "wb"); + if (fp == NULL) { + fprintf(stderr, "agiHost: cannot write %s\n", path); + return; + } + fprintf(fp, "P6\n%d %d\n%u\n", SURFACE_WIDTH, SURFACE_HEIGHT, HOST_CHANNEL_MAX); + fwrite(gDisplay, 1, sizeof(gDisplay), fp); + fclose(fp); +} + + +// ----- Port services ----- + +uint16_t jlpFrameCount(void) { + return (uint16_t)gFrame; +} + + +uint16_t jlpFrameHz(void) { + return HOST_FRAME_HZ; +} + + +bool jlpInit(const jlConfigT *config) { + (void)config; + return true; +} + + +void jlpInputInit(void) { +} + + +void jlpInputPoll(void) { + uint16_t k; + + for (k = 0u; k < (uint16_t)KEY_COUNT; k++) { + gKeyState[k] = (uint8_t)((gHeld[k] || gFrame < gHoldUntil[k]) ? 1u : 0u); + } +} + + +void jlpInputShutdown(void) { +} + + +void jlpJoystickReset(jlJoystickE js) { + (void)js; +} + + +void jlpPresent(const jlSurfaceT *src) { + uint16_t x; + uint16_t y; + const uint16_t *pal; + uint8_t byte; + uint8_t nib; + uint16_t rgb; + + for (y = 0u; y < SURFACE_HEIGHT; y++) { + pal = src->palette[src->scb[y]]; + for (x = 0u; x < SURFACE_WIDTH; x++) { + byte = src->pixels[(uint32_t)y * SURFACE_BYTES_PER_ROW + x / HOST_PIXELS_PER_BYTE]; + nib = (uint8_t)((x & 1u) ? (byte & 0x0Fu) : (byte >> 4)); + rgb = pal[nib]; + gDisplay[y][x][0] = (uint8_t)(((rgb >> 8) & 0x0Fu) * HOST_NIBBLE_SCALE); + gDisplay[y][x][1] = (uint8_t)(((rgb >> 4) & 0x0Fu) * HOST_NIBBLE_SCALE); + gDisplay[y][x][2] = (uint8_t)((rgb & 0x0Fu) * HOST_NIBBLE_SCALE); + } + } +} + + +void jlpShutdown(void) { +} + + +void jlpWaitVBL(void) { + gFrame++; + if (gRecording) { + hostRecordFrame(); + } + hostStep(); +} + + +// ----- Harness ----- + +// Read the input script from scriptPath; shots go to outDir ("-" for none). +bool hostScriptOpen(const char *scriptPath, const char *outDir) { + gScript = fopen(scriptPath, "r"); + if (gScript == NULL) { + fprintf(stderr, "agiHost: cannot read %s\n", scriptPath); + return false; + } + strncpy(gOutDir, outDir, sizeof(gOutDir) - 1u); + // Line by line, so a run killed by a timeout still shows how far it got. + setvbuf(stdout, NULL, _IOLBF, 0); + return true; +} diff --git a/tests/hostPort.h b/tests/hostPort.h new file mode 100644 index 0000000..8271e60 --- /dev/null +++ b/tests/hostPort.h @@ -0,0 +1,11 @@ +// The headless host port's harness (tests/hostPort.c): the input script +// that drives a run and where its shots go. + +#ifndef HOST_PORT_H +#define HOST_PORT_H + +#include + +bool hostScriptOpen(const char *scriptPath, const char *outDir); + +#endif diff --git a/tests/kq3/opening.scr b/tests/kq3/opening.scr new file mode 100644 index 0000000..e1027a8 --- /dev/null +++ b/tests/kq3/opening.scr @@ -0,0 +1,20 @@ +# The opening, untouched: the title, the credits over Manannan's face, the +# house and the story windows. Shots are sparse: a DOSBox screenshot +# (Ctrl+F5) reaches Sierra's interpreter as a key press and can hurry the +# opening along when scripts/oracle.sh runs it. +wait 3 +shot o01 +wait 7 +shot o02 +wait 7 +shot o03 +wait 13 +shot o04 +wait 15 +shot o05 +wait 15 +shot o06 +wait 15 +shot o07 +wait 15 +shot o08 diff --git a/tests/kq3/play.scr b/tests/kq3/play.scr new file mode 100644 index 0000000..92c661c --- /dev/null +++ b/tests/kq3/play.scr @@ -0,0 +1,57 @@ +# The first minutes of play: the opening skipped with Enter, Gwydion +# walked about his room, things looked at and typed (an unknown word too), +# the inventory, and the menus opened and walked. +wait 4 +key Return +wait 4 +shot g01 +key Left +wait 2 +shot g02 +key Left +wait 1 +shot g03 +key Down +wait 2 +shot g04 +key Down +wait 1 +type look +wait 0.5 +shot g05 +key Return +wait 2 +shot g06 +key Return +wait 1 +shot g07 +type look at bed +key Return +wait 2 +shot g08 +key Return +wait 1 +type xyzzy +key Return +wait 2 +shot g09 +key Return +wait 1 +key Tab +wait 2 +shot g10 +key Return +wait 2 +shot g11 +key Escape +wait 2 +shot g12 +key Right +wait 1 +shot g13 +key Down +wait 1 +shot g14 +key Escape +wait 2 +shot g15 diff --git a/tests/kq3/saverestore.scr b/tests/kq3/saverestore.scr new file mode 100644 index 0000000..ba596b9 --- /dev/null +++ b/tests/kq3/saverestore.scr @@ -0,0 +1,58 @@ +# Save (F5) to slot 3, walk away, then restore (F7) slot 3: the room, +# Gwydion's position and the status line come back, and the menus still +# work. Then restart (F9): the game starts again in the hall, menus and +# all (KQ3 builds its menus once and not on a restart). +wait 4 +key Return +wait 6 +clear +wait 6 +clear +key Left +wait 2 +key Left +wait 0.5 +shot s01 +key F5 +wait 1 +shot s02 +key 3 +wait 1 +shot s03 +type before the walk +key Return +wait 1 +clear +wait 1 +shot s04 +key Down +wait 3 +key Down +wait 0.5 +shot s05 +key F7 +wait 1 +shot s06 +key 3 +wait 2 +clear +wait 1 +shot s07 +key Escape +wait 1 +shot s08 +key Escape +wait 1 +key F9 +wait 1 +shot s09 +key Return +wait 6 +clear +wait 2 +shot s10 +key Escape +wait 1 +shot s11 +key Escape +wait 1 diff --git a/tests/kq3/teleport.scr b/tests/kq3/teleport.scr new file mode 100644 index 0000000..84423f5 --- /dev/null +++ b/tests/kq3/teleport.scr @@ -0,0 +1,52 @@ +# KQ3's own debug mode (Alt+D) and its "tp" command, from the hall to the +# beach and on, until Manannan comes home, finds Gwydion outside and takes +# him back (tp does nothing in some rooms, on Sierra's interpreter too). +wait 4 +key Return +wait 6 +clear +wait 6 +clear +wait 6 +clear +down alt +key d +up alt +wait 1 +clear +type tp +key Return +wait 0.5 +type 20 +key Return +wait 3 +shot r20 +clear +wait 1 +type tp +key Return +wait 0.5 +type 23 +key Return +wait 3 +shot r23 +clear +wait 1 +type tp +key Return +wait 0.5 +type 26 +key Return +wait 3 +shot r26 +clear +wait 1 +type tp +key Return +wait 0.5 +type 29 +key Return +wait 3 +shot r29 +clear +wait 1 diff --git a/tests/probes.py b/tests/probes.py new file mode 100755 index 0000000..7ec09d6 --- /dev/null +++ b/tests/probes.py @@ -0,0 +1,1257 @@ +#!/usr/bin/env python3 +# probes.py - the test games JoeyAGI's host gate runs (scripts/test-agi.sh) +# and scripts/oracle.sh runs on Sierra's own interpreter for comparison. +# +# Each probe is a tiny AGI game built from scratch with agiGame.py plus an +# input script (the format tests/hostPort.c and tests/tools/dosDrive.py +# both read). Most probes record what the interpreter did, cycle by cycle, +# into variables and print them with display at the end, so the result is +# the same however fast the machine runs. +# +# probes.py build OUTDIR [NAME...] write OUTDIR/NAME/ (game + probe.scr) +# probes.py list the probe names + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from agiGame import (Logic, cel, view, sound, writeGame, PIC_COLOR, PIC_NO_COLOR, PIC_PRIORITY, + PIC_NO_PRIORITY, PIC_Y_CORNER, PIC_X_CORNER, PIC_LINE, PIC_REL_LINE, PIC_FILL, + PIC_PEN, PIC_PLOT, PIC_END) + +# Variables and flags the interpreter owns (specification 3.4). +V_ROOM = 0 +V_EGO_EDGE = 2 +V_OBJ_EDGE_OBJ = 4 +V_OBJ_EDGE = 5 +V_EGO_DIR = 6 +V_BAD_WORD = 9 +V_CYCLE_DELAY = 10 +V_SECONDS = 11 +V_MINUTES = 12 +V_PRINT_TIMEOUT = 21 +F_EGO_ON_WATER = 0 +F_SAID_OK = 2 +F_EGO_TOUCHING = 3 +F_SAID_ACCEPTED = 4 +F_NEW_ROOM = 5 +F_SOUND = 9 + +# What the probes use for themselves. +V_INIT = 200 +V_PIC = 201 +V_CYCLE = 212 +PROBE_ROOM = 250 + +EMPTY_PIC = bytes([PIC_END]) + +DIR_RIGHT = 3 +DIR_DOWN = 5 +DIR_LEFT = 7 + + +# ----- Shared resources ----- + +def walkerCel(c, loop): + # 7x20: a head, a body with a marker that walks along it as the cel + # changes, and legs that alternate - every cel looks different. + head = ["..666..", ".66666.", ".66666.", "..666.."] + body = [] + for r in range(8): + row = list("1111111") + row[(c + r) % 7] = "e" + if loop == 0: + row[6] = "c" + elif loop == 3: + row[3] = "9" + body.append("".join(row)) + legs = ["22...22", ".22.22."] if c % 2 == 0 else [".22.22.", "22...22"] + return cel(head + body + legs * 4, transparent=0) + + +def walkerView(): + # Loops 0 right, 1 left (loop 0 flipped), 2 front, 3 back. + return view([[walkerCel(c, 0) for c in range(6)], + ("mirror", 0), + [walkerCel(c, 2) for c in range(4)], + [walkerCel(c, 3) for c in range(4)]]) + + +def boxView(): + # Loop 0: one 12x8 box. Loop 1: five 8x8 cels for the loop commands. + box = ["cccccccccccc"] + ["c4444444444c"] * 6 + ["cccccccccccc"] + frames = [] + for c in range(5): + frames.append(cel(["%x" % (c + 9) * 8] * (c + 4) + ["a.a.a.a."] * (4 - c), transparent=0)) + return view([[cel(box, transparent=0)], frames]) + + +def itemView(): + # An inventory close-up with a description, for show.obj. + art = [] + for r in range(12): + art.append("".join("%x" % ((r + x) % 6 + 9) for x in range(20))) + return view([[cel(art, transparent=0)]], description="A shiny test widget.\nIt does nothing at all.") + + +def postView(): + # A thin 3x40 post for priority tests. + return view([[cel(["555"] * 40, transparent=0)]]) + + +VIEWS = {0: walkerView(), 1: boxView(), 2: itemView(), 3: postView()} + + +def roomLogic(body): + # Logic 0 sends the game to the probe room; body(L) fills it in. + L0 = Logic() + with L0.If(("equaln", V_ROOM, 0)): + L0.new_room(PROBE_ROOM) + L0.call_v(V_ROOM) + L0.return_() + L = Logic() + body(L) + L.return_() + return {0: L0.build(), PROBE_ROOM: L.build()} + + +def showPic(L, num=1): + L.assignn(V_PIC, num) + L.load_pic(V_PIC) + L.draw_pic(V_PIC) + L.discard_pic(V_PIC) + + +def series(L, rows, cycles, record, base=20, stride=13, sep=" "): + # Record each row's value on cycles 1..cycles into v[base + i*stride + c], + # then display the rows two cycles later. + for c in range(1, cycles + 1): + with L.If(("equaln", V_CYCLE, c)): + for i, row in enumerate(rows): + record(L, row, base + i * stride + c) + with L.If(("equaln", V_CYCLE, cycles + 2)): + for i, row in enumerate(rows): + txt = row[0] + ":" + sep.join("%%v%d" % (base + i * stride + c) for c in range(1, cycles + 1)) + L.display(i + 1, 0, L.m(txt)) + + +def recordFlag(L, flag, var): + with L.If(("isset", flag)): + L.assignn(var, 1) + + +# ----- Probes ----- + +def probePictures(): + # Every picture command: colours on and off, corners, absolute and + # relative lines, nested fills (visual, priority-only and both) and + # every pen: solid and splatter, circle and rectangle, all sizes. + p = bytearray() + p += bytes([PIC_COLOR, 1, PIC_PRIORITY, 4, PIC_LINE, 0, 0, 159, 0, 159, 167, 0, 167, 0, 0]) + p += bytes([PIC_COLOR, 2, PIC_PRIORITY, 7, PIC_LINE, 10, 10, 70, 10, 70, 60, 10, 60, 10, 10]) + p += bytes([PIC_FILL, 30, 30]) + p += bytes([PIC_COLOR, 14, PIC_LINE, 20, 20, 60, 50, PIC_LINE, 60, 20, 20, 50]) + p += bytes([PIC_FILL, 40, 24, 22, 35, 58, 35]) + p += bytes([PIC_NO_COLOR, PIC_PRIORITY, 11, PIC_LINE, 80, 10, 150, 10, 150, 60, 80, 60, 80, 10]) + p += bytes([PIC_FILL, 100, 30]) + p += bytes([PIC_COLOR, 4, PIC_NO_PRIORITY, PIC_Y_CORNER, 85, 15, 95, 25, 105, 35, 115]) + p += bytes([PIC_X_CORNER, 120, 15, 130, 25, 140, 35]) + p += bytes([PIC_COLOR, 5, PIC_PRIORITY, 9, PIC_REL_LINE, 100, 45, 0x22, 0x40, 0x06, 0xCC, 0x77, 0x71, 0x99]) + p += bytes([PIC_COLOR, 6, PIC_LINE, 5, 70, 154, 160, PIC_LINE, 154, 70, 5, 160, PIC_LINE, 80, 66, 80, 166, PIC_LINE, 3, 115, 156, 115]) + p += bytes([PIC_COLOR, 3, PIC_PRIORITY, 13, PIC_FILL, 80, 100, 40, 115, 120, 115, 80, 140]) + x = 12 + for pen in range(16): + style = (0x20 if pen >= 8 else 0) | (0x10 if pen % 2 else 0) | (pen % 8) + p += bytes([PIC_COLOR, 1 + (pen % 14), PIC_PRIORITY, 4 + (pen % 10), PIC_PEN, style, PIC_PLOT]) + y = 78 if pen < 8 else 98 + if style & 0x20: + p += bytes([(pen * 13) & 0xFE, x, y, (pen * 29) & 0xFE, x + 4, y + 10]) + else: + p += bytes([x, y, x + 4, y + 10]) + x = x + 18 if pen != 7 else 12 + p += bytes([PIC_PEN, 0x00, PIC_PLOT, 150, 160, 151, 161]) + p.append(PIC_END) + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + showPic(L) + L.show_pic() + with L.If(("have.key",)): + L.show_pri_screen() + script = "wait 3\nshot visual\nkey Return\nwait 2\nshot priority\n" + return dict(logics=roomLogic(body), pics={1: bytes(p)}), script + + +def probeWindows(): + # print wrapping (the 30-column default, long words, explicit breaks), + # print.at, display, text attributes and the status line. + msgs = ["Hello.", + "This message is exactly thirty.", + "A message that is a good deal longer than forty characters, so it must wrap across lines.", + "Line one\nLine two is longer\nThree", + "A lad of seventeen who has lived for as long as he can remember with a wizard, in a house on a mountain far from anywhere.", + "Supercalifragilisticexpialidociousantidisestablishmentarianism word"] + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + showPic(L) + L.show_pic() + for t in msgs: + L.print(L.m(t)) + L.print_at(L.m("print.at 2,3 width 20 with some text to wrap"), 2, 3, 20) + L.print_at(L.m("print.at 10,2 width 25"), 10, 2, 25) + L.display(22, 0, L.m("display at row 22 col 0")) + L.set_text_attribute(14, 1) + L.display(23, 5, L.m("attr 14,1 row 23 col 5")) + L.print(L.m("after display")) + L.status_line_on() + L.print(L.m("status on")) + L.clear_lines(22, 22, 0) + L.print(L.m("row 22 cleared")) + script = "wait 3\nshot w01\n" + "".join("key Return\nwait 1.5\nshot w%02d\n" % i for i in range(2, 13)) + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}), script + + +def probeTiming(): + # Motion and animation counters cycle by cycle: step.size/step.time, + # move.obj, end.of.loop, cycle.time and reverse.loop. + N = 12 + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 2) + showPic(L) + L.load_view(0) + L.load_view(1) + L.assignn(202, 1) + L.assignn(203, DIR_RIGHT) + L.assignn(204, 2) + + def mk(o, x, y, v, loop): + L.animate_obj(o) + L.set_view(o, v) + L.set_loop(o, loop) + L.set_cel(o, 0) + L.position(o, x, y) + L.ignore_objs(o) + L.fix_loop(o) + mk(1, 20, 60, 0, 0) + L.set_dir(1, 203) + L.draw(1) + mk(2, 20, 100, 0, 0) + L.draw(2) + L.move_obj(2, 30, 100, 3, 50) + mk(3, 20, 140, 1, 1) + L.draw(3) + L.end_of_loop(3, 51) + mk(4, 90, 60, 0, 0) + L.step_time(4, 204) + L.set_dir(4, 203) + L.draw(4) + mk(5, 90, 100, 0, 0) + L.cycle_time(5, 204) + L.draw(5) + mk(6, 90, 140, 1, 1) + L.set_cel(6, 3) + L.draw(6) + L.reverse_loop(6, 52) + L.show_pic() + L.increment(V_CYCLE) + rows = [("x1", lambda L, v: L.get_posn(1, v, 99)), ("c1", lambda L, v: L.current_cel(1, v)), + ("x2", lambda L, v: L.get_posn(2, v, 99)), ("f50", lambda L, v: recordFlag(L, 50, v)), + ("c3", lambda L, v: L.current_cel(3, v)), ("f51", lambda L, v: recordFlag(L, 51, v)), + ("x4", lambda L, v: L.get_posn(4, v, 99)), ("c5", lambda L, v: L.current_cel(5, v)), + ("c6", lambda L, v: L.current_cel(6, v)), ("f52", lambda L, v: recordFlag(L, 52, v))] + with L.If(("equaln", V_CYCLE, N + 2)): + for o in range(1, 7): + L.erase(o) + series(L, rows, N, lambda L, row, v: row[1](L, v)) + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=VIEWS), "wait 5\nshot a\n" + + +def probeMotion(): + # step.size against move.obj's arrival, move.obj's own step size, + # diagonal moves and a move to where the object already is. + N = 12 + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 2) + showPic(L) + L.load_view(0) + L.assignn(202, 1) + L.assignn(203, 2) + L.assignn(204, 5) + + def mk(o, x, y): + L.animate_obj(o) + L.set_view(o, 0) + L.set_loop(o, 0) + L.set_cel(o, 0) + L.position(o, x, y) + L.ignore_objs(o) + L.stop_cycling(o) + mk(1, 20, 60) + L.draw(1) + L.move_obj(1, 31, 60, 3, 50) + mk(2, 20, 80) + L.step_size(2, 203) + L.draw(2) + L.move_obj(2, 31, 80, 3, 51) + mk(3, 20, 100) + L.step_size(3, 204) + L.draw(3) + L.move_obj(3, 31, 100, 0, 52) + mk(4, 20, 140) + L.draw(4) + L.move_obj(4, 40, 120, 2, 53) + mk(5, 100, 140) + L.draw(5) + L.move_obj(5, 100, 140, 2, 54) + L.show_pic() + L.increment(V_CYCLE) + rows = [("x1", lambda L, v: L.get_posn(1, v, 19)), ("f50", lambda L, v: recordFlag(L, 50, v)), + ("x2", lambda L, v: L.get_posn(2, v, 19)), ("f51", lambda L, v: recordFlag(L, 51, v)), + ("x3", lambda L, v: L.get_posn(3, v, 19)), ("f52", lambda L, v: recordFlag(L, 52, v)), + ("x4", lambda L, v: L.get_posn(4, v, 19)), ("y4", lambda L, v: L.get_posn(4, 19, v)), + ("f53", lambda L, v: recordFlag(L, 53, v)), ("f54", lambda L, v: recordFlag(L, 54, v)), + ("d1", lambda L, v: L.get_dir(1, v))] + with L.If(("equaln", V_CYCLE, N + 2)): + for o in range(1, 6): + L.erase(o) + series(L, rows, N, lambda L, row, v: row[1](L, v)) + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=VIEWS), "wait 5\nshot a\n" + + +def probeCounters(): + # When step and cycle counters start: objects drawn before or after + # set.dir, step.time and cycle.time, and end.of.loop on drawn objects. + N = 12 + tests = [("A", 1, 30, "x"), ("B", 2, 40, "x"), ("C", 3, 50, "x"), ("D", 4, 60, "x"), ("E", 5, 70, "x"), + ("F", 6, 80, "x"), ("G", 7, 90, "c"), ("H", 8, 100, "c"), ("I", 9, 110, "c"), ("J", 10, 120, "x"), + ("K", 11, 130, "x")] + + def body(L): + def mk(o, y, loop=0): + L.animate_obj(o) + L.set_view(o, 0) + L.set_loop(o, loop) + L.set_cel(o, 0) + L.position(o, 20, y) + L.ignore_objs(o) + L.fix_loop(o) + L.stop_cycling(o) + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 2) + showPic(L) + L.load_view(0) + for var, val in ((202, 1), (203, DIR_RIGHT), (204, 2), (205, 3)): + L.assignn(var, val) + mk(1, 30) + L.set_dir(1, 203) + L.draw(1) + mk(2, 40) + L.draw(2) + L.set_dir(2, 203) + mk(3, 50) + L.step_time(3, 204) + L.set_dir(3, 203) + L.draw(3) + mk(4, 60) + L.draw(4) + L.step_time(4, 204) + L.set_dir(4, 203) + mk(5, 70) + L.step_time(5, 205) + L.set_dir(5, 203) + L.draw(5) + mk(6, 80) + L.step_time(6, 202) + L.set_dir(6, 203) + L.draw(6) + mk(7, 90) + L.cycle_time(7, 205) + L.start_cycling(7) + L.draw(7) + mk(8, 100, 2) + L.end_of_loop(8, 51) + L.draw(8) + mk(9, 110, 2) + L.cycle_time(9, 204) + L.draw(9) + L.end_of_loop(9, 52) + mk(10, 120) + L.draw(10) + L.show_pic() + L.increment(V_CYCLE) + with L.If(("equaln", V_CYCLE, 3)): + L.set_dir(10, 203) + mk(11, 130) + L.set_dir(11, 203) + L.draw(11) + with L.If(("equaln", V_CYCLE, N + 2)): + for t in tests: + L.erase(t[1]) + + def record(L, t, v): + if t[3] == "x": + L.get_posn(t[1], v, 99) + else: + L.current_cel(t[1], v) + series(L, tests, N, record) + 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. + N = 12 + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 2) + showPic(L) + L.load_view(0) + L.assignn(203, DIR_RIGHT) + for o in range(1, 8): + L.animate_obj(o) + L.set_view(o, 0) + L.set_loop(o, 0) + L.set_cel(o, 0) + L.position(o, 20, 20 + 15 * o) + L.ignore_objs(o) + L.fix_loop(o) + L.stop_cycling(o) + L.set_dir(o, 203) + L.draw(o) + L.show_pic() + L.increment(V_CYCLE) + with L.If(("equaln", V_CYCLE, 5)): + L.erase(1) + L.set_view(1, 0) + L.draw(1) + L.erase(2) + L.draw(2) + L.erase(3) + L.animate_obj(3) + L.draw(3) + L.erase(4) + L.set_loop(4, 0) + L.draw(4) + L.erase(5) + L.set_cel(5, 0) + L.draw(5) + L.set_view(6, 0) + L.erase(7) + L.position(7, 20, 125) + L.draw(7) + with L.If(("equaln", V_CYCLE, N + 2)): + for o in range(1, 8): + L.erase(o) + rows = [("%d" % o, o) for o in range(1, 8)] + series(L, rows, N, lambda L, row, v: L.get_posn(row[1], v, 19)) + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=VIEWS), "wait 5\nshot a\n" + + +def probeSetView(): + # set.view and set.loop with loops and cels the new view lacks. + def body(L): + with L.If(("isset", F_NEW_ROOM)): + showPic(L) + for v in (0, 1, 2, 3): + L.load_view(v) + + def mk(o, v, loop, c): + L.animate_obj(o) + L.set_view(o, v) + L.set_loop(o, loop) + L.set_cel(o, c) + + def rec(o, base): + L.current_loop(o, base) + L.current_cel(o, base + 1) + L.current_view(o, base + 2) + mk(1, 0, 3, 3) + L.set_view(1, 1) + rec(1, 150) + mk(2, 0, 0, 5) + L.set_loop(2, 2) + rec(2, 153) + mk(3, 0, 2, 3) + L.set_view(3, 2) + rec(3, 156) + mk(4, 1, 1, 4) + L.set_view(4, 3) + rec(4, 159) + mk(5, 0, 1, 2) + L.set_view(5, 1) + rec(5, 162) + L.show_pic() + L.display(1, 0, L.m("A %v150/%v151 B %v153/%v154 C %v156/%v157")) + L.display(2, 0, L.m("D %v159/%v160 E %v162/%v163")) + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=VIEWS), "wait 3\nshot a\n" + + +def controlPicture(): + # Control lines on the priority screen, drawn in matching colours. + p = bytearray() + + def vline(pri, x, y0, y1, vis): + p.extend([PIC_COLOR, vis, PIC_PRIORITY, pri, PIC_LINE, x, y0, x, y1]) + vline(0, 60, 20, 60, 0) + vline(1, 60, 70, 110, 1) + vline(2, 60, 120, 140, 2) + p.extend([PIC_COLOR, 3, PIC_PRIORITY, 3, PIC_LINE, 100, 145, 140, 145, 140, 165, 100, 165, 100, 145, PIC_FILL, 120, 155]) + p.append(PIC_END) + return bytes(p) + + +def probeControl(): + # Barriers (unconditional and conditional), signal lines, water and + # land, and priority 15 ignoring them all; ego crossing a signal line + # (f3) and the first object to reach an edge (v4, v5). Positions are + # recorded at cycles 20, 60 and 140, then shown. + objs = [(1, 40, ""), (2, 50, "ib"), (3, 80, ""), (4, 95, "ib"), (0, 130, ""), (6, 40, "p15"), (7, 90, "p15"), + (8, 155, ""), (9, 155, "water"), (10, 100, "land")] + snaps = (20, 60, 140) + done = 150 + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 1) + showPic(L) + L.load_view(0) + L.assignn(202, 1) + L.assignn(203, DIR_RIGHT) + for (o, y, fl) in objs: + L.animate_obj(o) + L.set_view(o, 0) + L.set_loop(o, 0) + L.set_cel(o, 0) + x = {8: 85, 9: 110}.get(o, 40) + L.position(o, x, y) + L.step_size(o, 202) + L.step_time(o, 202) + if "ib" in fl: + L.ignore_blocks(o) + if "p15" in fl: + L.set_priority(o, 15) + if fl == "water": + L.object_on_water(o) + if fl == "land": + L.object_on_land(o) + L.draw(o) + L.set_dir(o, 203) + L.show_pic() + L.increment(V_CYCLE) + with L.If(("!", "equaln", V_OBJ_EDGE, 0), ("equaln", 216, 0)): + L.assignv(216, V_OBJ_EDGE_OBJ) + L.assignv(217, V_OBJ_EDGE) + L.assignv(218, V_CYCLE) + with L.If(("isset", F_EGO_TOUCHING), ("equaln", 219, 0)): + L.assignv(219, V_CYCLE) + for k, when in enumerate(snaps): + with L.If(("equaln", V_CYCLE, when)): + for i, (o, y, fl) in enumerate(objs): + L.get_posn(o, 150 + 10 * k + i, 99) + with L.If(("equaln", V_CYCLE, done)): + for (o, y, fl) in objs: + L.erase(o) + for k, when in enumerate(snaps): + L.display(1 + 2 * k, 0, L.m("t%d: " % when + " ".join("%%v%d" % (150 + 10 * k + i) for i in range(5)))) + L.display(2 + 2 * k, 0, L.m(" " + " ".join("%%v%d" % (150 + 10 * k + i) for i in range(5, 10)))) + L.display(8, 0, L.m("edge obj %v216 edge %v217 at %v218")) + L.display(9, 0, L.m("f3 first at %v219")) + return dict(logics=roomLogic(body), pics={1: controlPicture()}, views=VIEWS), "wait 10\nshot a\n" + + +def probeCollide(): + # Objects meeting objects, and where draw puts an object that starts + # on a barrier, on another object, off the edge or above the horizon. + pic = bytes([PIC_NO_COLOR, PIC_PRIORITY, 0, PIC_LINE, 100, 120, 100, 160, PIC_LINE, 10, 150, 30, 150, PIC_END]) + + def body(L): + def mk(o, x, y): + L.animate_obj(o) + L.set_view(o, 0) + L.set_loop(o, 0) + L.set_cel(o, 0) + L.stop_cycling(o) + L.position(o, x, y) + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 2) + showPic(L) + L.load_view(0) + L.assignn(203, DIR_RIGHT) + L.assignn(207, DIR_LEFT) + L.assignn(205, DIR_DOWN) + mk(1, 20, 40) + L.draw(1) + L.set_dir(1, 203) + mk(2, 60, 40) + L.draw(2) + L.set_dir(2, 207) + mk(3, 20, 60) + L.draw(3) + L.set_dir(3, 203) + mk(4, 50, 60) + L.draw(4) + L.stop_update(4) + mk(5, 70, 75) + L.draw(5) + L.set_dir(5, 205) + mk(6, 68, 90) + L.draw(6) + L.stop_update(6) + mk(7, 97, 130) + L.draw(7) + mk(8, 120, 100) + L.draw(8) + mk(9, 121, 100) + L.draw(9) + mk(10, 158, 110) + L.draw(10) + mk(11, 140, 20) + L.draw(11) + mk(12, 15, 150) + L.draw(12) + L.show_pic() + L.increment(V_CYCLE) + with L.If(("equaln", V_CYCLE, 30)): + for o in range(1, 13): + L.get_posn(o, 150 + 2 * o, 151 + 2 * o) + for o in range(1, 13): + L.erase(o) + for row in range(3): + txt = " ".join("%d:%%v%d,%%v%d" % (o, 150 + 2 * o, 151 + 2 * o) for o in range(1 + 4 * row, 5 + 4 * row)) + L.display(row + 1, 0, L.m(txt)) + return dict(logics=roomLogic(body), pics={1: pic}, views=VIEWS), "wait 6\nshot a\n" + + +def probePriority(): + # Objects at fixed priorities passing through a priority band and a + # fixed-priority static object, then add.to.pic with every margin. + pic = bytes([PIC_PRIORITY, 9, PIC_LINE, 0, 100, 159, 100, PIC_COLOR, 1, PIC_PRIORITY, 7, PIC_LINE, 0, 60, 159, 60, + PIC_COLOR, 2, PIC_PRIORITY, 12, PIC_LINE, 0, 130, 159, 130, 159, 167, 0, 167, 0, 130, PIC_FILL, 80, 150, + PIC_END]) + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 2) + showPic(L) + for v in (0, 1, 3): + L.load_view(v) + L.animate_obj(1) + L.set_view(1, 1) + L.position(1, 60, 120) + L.set_priority(1, 12) + L.ignore_objs(1) + L.ignore_blocks(1) + L.draw(1) + L.stop_update(1) + L.animate_obj(2) + L.set_view(2, 0) + L.position(2, 40, 122) + L.set_priority(2, 6) + L.ignore_objs(2) + L.ignore_blocks(2) + L.assignn(202, 1) + L.step_size(2, 202) + L.assignn(203, DIR_RIGHT) + L.set_dir(2, 203) + L.draw(2) + L.animate_obj(3) + L.set_view(3, 0) + L.position(3, 30, 140) + L.ignore_objs(3) + L.set_dir(3, 203) + L.draw(3) + L.add_to_pic(0, 0, 0, 10, 50, 0, 4) + L.add_to_pic(0, 0, 0, 25, 50, 12, 0) + L.add_to_pic(0, 0, 0, 120, 90, 6, 1) + L.add_to_pic(0, 2, 0, 135, 125, 0, 2) + L.add_to_pic(0, 2, 1, 150, 125, 5, 3) + L.add_to_pic(3, 0, 0, 100, 110, 15, 0) + L.show_pic() + with L.If(("have.key",)): + L.show_pri_screen() + L.increment(V_CYCLE) + # Freeze everything where it is after 15 and 40 cycles. + for when, row in ((15, 22), (40, 23)): + with L.If(("equaln", V_CYCLE, when)): + for o in (2, 3): + L.stop_motion(o) + L.stop_cycling(o) + L.get_posn(2, 210, 211) + L.get_posn(3, 213, 214) + L.display(row, 0, L.m("c%d: 2 at %%v210,%%v211 3 at %%v213,%%v214" % when)) + with L.If(("equaln", V_CYCLE, 25)): + for o in (2, 3): + L.start_motion(o) + L.start_cycling(o) + L.set_dir(o, 203) + script = "wait 2\nshot a\nwait 3\nshot b\nkey Return\nwait 1\nshot p\n" + return dict(logics=roomLogic(body), pics={1: pic}, views=VIEWS), script + + +def probeFollow(): + # follow.ego from near and far, with and without a bigger step. + N = 9 + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 1) + showPic(L) + L.load_view(0) + L.animate_obj(0) + L.set_view(0, 0) + L.position(0, 80, 120) + L.draw(0) + L.animate_obj(1) + L.set_view(1, 0) + L.position(1, 20, 60) + L.draw(1) + L.follow_ego(1, 10, 50) + L.animate_obj(2) + L.set_view(2, 0) + L.position(2, 140, 160) + L.draw(2) + L.follow_ego(2, 1, 51) + L.assignn(202, 2) + L.step_size(2, 202) + L.show_pic() + L.increment(V_CYCLE) + for k in range(1, N + 1): + with L.If(("equaln", V_CYCLE, 10 * k)): + L.get_posn(1, 150 + k, 160 + k) + L.get_posn(2, 170 + k, 180 + k) + recordFlag(L, 50, 190 + k) + recordFlag(L, 51, 230 + k) + with L.If(("equaln", V_CYCLE, 10 * N + 2)): + for o in range(3): + L.erase(o) + for row, (name, base) in enumerate((("x1", 150), ("y1", 160), ("x2", 170), ("y2", 180), ("f50", 190), ("f51", 230))): + L.display(row + 1, 0, L.m(name + ":" + " ".join("%%v%d" % (base + k) for k in range(1, N + 1)))) + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=VIEWS), "wait 7\nshot a\n" + + +def probeEgo(): + # The player walking ego with the arrow keys: x and loop for the first + # 12 cycles of walking left, then ego stopped by a barrier, the bottom + # edge (v2) and the horizon. A letter key shows the results. (The cel + # depends on when the key went down, so it is left out.) + N = 12 + pic = bytes([PIC_COLOR, 0, PIC_PRIORITY, 0, PIC_LINE, 40, 0, 40, 100, PIC_END]) + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 2) + showPic(L) + L.load_view(0) + L.animate_obj(0) + L.set_view(0, 0) + L.position(0, 80, 80) + L.draw(0) + L.show_pic() + with L.If(("!", "equaln", V_EGO_DIR, 0), ("lessn", V_CYCLE, N)): + L.increment(V_CYCLE) + for c in range(1, N + 1): + with L.If(("equaln", V_CYCLE, c)): + L.get_posn(0, 150 + c, 99) + L.current_loop(0, 165 + c) + with L.If(("!", "equaln", V_EGO_EDGE, 0), ("equaln", 199, 0)): + L.assignv(199, V_EGO_EDGE) + L.get_posn(0, 197, 198) + L.current_loop(0, 196) + with L.If(("have.key",)): + L.get_posn(0, 194, 195) + L.current_loop(0, 193) + L.erase(0) + L.display(1, 0, L.m("x:" + " ".join("%%v%d" % (150 + c) for c in range(1, N + 1)))) + L.display(2, 0, L.m("loop:" + " ".join("%%v%d" % (165 + c) for c in range(1, N + 1)))) + L.display(4, 0, L.m("edge %v199 at %v197,%v198 loop %v196")) + L.display(5, 0, L.m("end %v194,%v195 loop %v193 dir %v6")) + script = "wait 2\nkey Left\nwait 6\nkey Down\nwait 10\nkey Up\nwait 15\nkey a\nwait 1\nshot a\n" + return dict(logics=roomLogic(body), pics={1: pic}, views=VIEWS), script + + +def probeEgoDirection(): + # What steers ego: set.dir and v6 under player and program control, + # and move.obj on ego. Each phase restarts ego at (80,100) and reads + # its direction after 3 cycles and its position after 10. + phases = [("A", lambda L: L.set_dir(0, 203)), + ("B", lambda L: L.assignn(V_EGO_DIR, DIR_LEFT)), + ("C", lambda L: (L.program_control(), L.set_dir(0, 205))), + ("D", lambda L: L.assignn(V_EGO_DIR, 1)), + ("E", lambda L: L.move_obj(0, 80, 60, 1, 60)), + ("F", lambda L: (L.player_control(), L.set_dir(0, 207)))] + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 1) + showPic(L) + L.load_view(0) + L.animate_obj(0) + L.set_view(0, 0) + L.position(0, 80, 100) + L.draw(0) + L.show_pic() + L.assignn(203, DIR_RIGHT) + L.assignn(205, DIR_DOWN) + L.assignn(207, DIR_LEFT) + L.increment(V_CYCLE) + for i, (name, setup) in enumerate(phases): + start = 5 + i * 12 + with L.If(("equaln", V_CYCLE, start)): + L.stop_motion(0) + L.reposition_to(0, 80, 100) + L.start_motion(0) + setup(L) + with L.If(("equaln", V_CYCLE, start + 3)): + L.get_dir(0, 150 + 4 * i) + L.assignv(151 + 4 * i, V_EGO_DIR) + with L.If(("equaln", V_CYCLE, start + 10)): + L.get_posn(0, 152 + 4 * i, 153 + 4 * i) + with L.If(("equaln", V_CYCLE, 5 + 12 * len(phases))): + L.erase(0) + for i, (name, setup) in enumerate(phases): + L.display(1 + i, 0, L.m("%s dir %%v%d v6 %%v%d at %%v%d,%%v%d" % (name, 150 + 4 * i, 151 + 4 * i, 152 + 4 * i, 153 + 4 * i))) + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=VIEWS), "wait 6\nshot a\n" + + +def probeSteering(): + # The cycle a change of steering takes effect: v6 set by a logic, then + # cleared; set.dir on ego; reposition.to and reposition of a walking + # object; move.obj of an object and of ego. Each row is the x read by + # the logic on seven cycles from the one before the change. + rows = [("G", 0, 4), ("H", 0, 19), ("I", 1, 29), ("J", 2, 39), ("K", 0, 49), ("L", 1, 59)] + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 1) + showPic(L) + L.load_view(0) + for o, x, y in ((0, 80, 100), (1, 20, 120), (2, 80, 140)): + L.animate_obj(o) + L.set_view(o, 0) + L.position(o, x, y) + L.draw(o) + L.show_pic() + L.assignn(203, DIR_RIGHT) + L.assignn(207, DIR_LEFT) + L.assignn(204, 5) + L.assignn(205, 0) + L.set_dir(1, 203) + L.increment(V_CYCLE) + with L.If(("equaln", V_CYCLE, 5)): + L.assignn(V_EGO_DIR, DIR_RIGHT) + with L.If(("equaln", V_CYCLE, 15)): + L.assignn(V_EGO_DIR, 0) + with L.If(("equaln", V_CYCLE, 20)): + L.set_dir(0, 207) + with L.If(("equaln", V_CYCLE, 30)): + L.reposition_to(1, 80, 120) + with L.If(("equaln", V_CYCLE, 40)): + L.move_obj(2, 120, 140, 1, 61) + with L.If(("equaln", V_CYCLE, 50)): + L.move_obj(0, 120, 100, 1, 62) + with L.If(("equaln", V_CYCLE, 60)): + L.reposition(1, 204, 205) + for i, (name, o, start) in enumerate(rows): + for k in range(7): + with L.If(("equaln", V_CYCLE, start + k)): + L.get_posn(o, 100 + i * 10 + k, 99) + with L.If(("equaln", V_CYCLE, 70)): + for o in (0, 1, 2): + L.erase(o) + for i, (name, o, start) in enumerate(rows): + L.display(1 + i, 0, L.m("%s c%d: " % (name, start) + " ".join("%%v%d" % (100 + i * 10 + k) for k in range(7)))) + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=VIEWS), "wait 5\nshot a\n" + + +def probeCycling(): + # Cels of ego standing, walking (v6) and stopping, under player and + # program control, beside an object that walks and is stopped by + # stop.motion and one that never moves. + N = 30 + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 1) + showPic(L) + L.load_view(0) + for o, y in ((0, 60), (1, 90), (2, 120)): + L.animate_obj(o) + L.set_view(o, 0) + L.position(o, 80, y) + L.draw(o) + L.show_pic() + L.assignn(203, DIR_RIGHT) + L.increment(V_CYCLE) + with L.If(("equaln", V_CYCLE, 5)): + L.assignn(V_EGO_DIR, DIR_RIGHT) + L.set_dir(2, 203) + with L.If(("equaln", V_CYCLE, 10)): + L.assignn(V_EGO_DIR, 0) + L.stop_motion(2) + with L.If(("equaln", V_CYCLE, 18)): + L.program_control() + with L.If(("equaln", V_CYCLE, 22)): + L.assignn(V_EGO_DIR, DIR_RIGHT) + with L.If(("equaln", V_CYCLE, 26)): + L.assignn(V_EGO_DIR, 0) + rows = [("e", 0), ("s", 1), ("w", 2)] + with L.If(("equaln", V_CYCLE, N + 2)): + for o in (0, 1, 2): + L.erase(o) + series(L, rows, N, lambda L, row, v: L.current_cel(row[1], v), base=100, stride=35, sep="") + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=VIEWS), "wait 4\nshot a\n" + + +def probeClock(): + # A print with v21 set closes by itself after v21 half-seconds: + # measured against the clock (v11 counts real seconds), each window + # opening just after v11 ticks; the cycles from its closing to the + # next tick give the fraction. Then windows that wait for Enter, with + # a letter, space and Escape pressed at them. + timeouts = [6, 4, 2, 1] + phase = 230 + prev = 231 + index = 232 + count = 233 + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 1) + showPic(L) + L.show_pic() + L.assignv(prev, V_SECONDS) + for i, k in enumerate(timeouts): + with L.If(("equaln", index, i)): + with L.If(("equaln", phase, 0), ("!", "equalv", V_SECONDS, prev)): + L.assignv(150 + 3 * i, V_SECONDS) + L.assignn(V_PRINT_TIMEOUT, k) + L.print(L.m("Timed window: v21=%d" % k)) + L.assignn(V_PRINT_TIMEOUT, 0) + L.assignv(prev, V_SECONDS) + L.assignn(phase, 1) + L.assignn(count, 0) + with L.If(("equaln", phase, 1)): + L.increment(count) + with L.If(("!", "equalv", V_SECONDS, prev)): + L.assignv(151 + 3 * i, V_SECONDS) + L.assignv(152 + 3 * i, count) + L.assignn(phase, 0) + L.assignv(prev, V_SECONDS) + L.increment(index) + with L.If(("equaln", index, len(timeouts))): + L.increment(index) + for i, k in enumerate(timeouts): + L.display(1 + i, 0, L.m("v21=%d: open at %%v%d, next tick %%v%d, %%v%d cycles after" % (k, 150 + 3 * i, 151 + 3 * i, 152 + 3 * i))) + L.print(L.m("Press a letter key")) + L.print(L.m("Press space")) + L.print(L.m("Press Escape")) + L.print(L.m("Done")) + script = "wait 22\nshot k0\nkey a\nwait 1\nshot k1\nkey space\nwait 1\nshot k2\nkey Escape\nwait 1\nshot k3\n" + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}), script + + +# Words for the parser probe. 0 is ignored, 1 is any word and 9999 the +# rest of the line. +VOCAB = {"the": 0, "a": 0, "at": 0, "look": 10, "examine": 10, "get": 11, "take": 11, "pick up": 11, + "widget": 20, "box": 21, "door": 22, "open": 12, "inventory": 13, "inv": 13, "quit": 14, "show": 15} +ITEMS = [("?", 0), ("test widget", 255), ("blue box", 250), ("old key", 0)] + + +def probeParser(): + # Typed input: said with synonyms, ignored words, multi-word entries, + # anyword and rest-of-line; an unknown word (v9, f2/f4); the menu bar + # and a menu item; the inventory screen and show.obj. + ctl = {"file": 1, "about": 2, "inv": 3, "widget": 4, "off": 5, "menu": 20} + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 2) + showPic(L) + L.show_pic() + L.load_view(2) + L.status_line_on() + L.accept_input() + L.set_key(27, 0, ctl["menu"]) + L.set_menu(L.m("File")) + L.set_menu_item(L.m("About "), ctl["about"]) + L.set_menu_item(L.m("Off item"), ctl["off"]) + L.set_menu(L.m("Things")) + L.set_menu_item(L.m("Inventory"), ctl["inv"]) + L.set_menu_item(L.m("Widget "), ctl["widget"]) + L.submit_menu() + L.disable_item(ctl["off"]) + with L.If(("controller", ctl["menu"])): + L.menu_input() + with L.If(("controller", ctl["about"])): + L.print(L.m("About was chosen.")) + with L.If(("or", [("controller", ctl["inv"]), ("said", 13)])): + L.status() + with L.If(("controller", ctl["widget"])): + L.show_obj(2) + with L.If(("said", 10)): + L.print(L.m("You look around.")) + with L.If(("said", 10, 20)): + L.print(L.m("It is a widget.")) + with L.If(("said", 11, 20)): + L.get(1) + L.print(L.m("Taken.")) + with L.If(("said", 11, 1)): + L.print(L.m("Take what? (%w2)")) + with L.If(("said", 12, 9999)): + L.print(L.m("Open: rest of line.")) + with L.If(("isset", F_SAID_OK), ("!", "isset", F_SAID_ACCEPTED)): + L.print(L.m("No match. v9=%v9")) + L.display(22, 0, L.m("f2=%v214 f4=%v215 v9=%v9 ")) + L.assignn(214, 0) + L.assignn(215, 0) + recordFlag(L, F_SAID_OK, 214) + recordFlag(L, F_SAID_ACCEPTED, 215) + script = ("wait 2\nshot p01\n" + "type look\nkey Return\nwait 1\nshot p02\nkey Return\nwait 1\n" + "type look at the widget\nkey Return\nwait 1\nshot p03\nkey Return\nwait 1\n" + "type examine box\nkey Return\nwait 1\nshot p04\nkey Return\nwait 1\n" + "type take box\nkey Return\nwait 1\nshot p05\nkey Return\nwait 1\n" + "type pick up widget\nkey Return\nwait 1\nshot p06\nkey Return\nwait 1\n" + "type open the door now\nkey Return\nwait 1\nshot p07\nkey Return\nwait 1\n" + "type xyzzy plugh\nkey Return\nwait 1\nshot p08\nkey Return\nwait 1\n" + "type look widget, please\nkey Return\nwait 1\nshot p09\nkey Return\nwait 1\n" + "type inv\nkey Return\nwait 1\nshot p10\nkey Return\nwait 1\n" + "key Escape\nwait 1\nshot m01\nkey Down\nwait 0.5\nshot m02\nkey Return\nwait 1\nshot m03\nkey Return\nwait 1\n" + "key Escape\nwait 1\nkey Right\nwait 0.5\nshot m04\nkey Down\nwait 0.5\nkey Return\nwait 1\nshot m05\nkey Return\nwait 1\n" + "key Escape\nwait 1\nkey Right\nwait 0.5\nkey Return\nwait 1\nshot m06\nkey Return\nwait 1\nshot m07\n") + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, views=VIEWS, vocabulary=VOCAB, items=ITEMS), script + + +def probeSound(): + # When a sound's flag is set: tunes of known lengths (1/60 s ticks), + # one note or many; the first voice decides, however long the others + # (Sierra's PC interpreter plays only that one), an empty first voice + # ends at once; a sound started with sound off (f9 clear) and one cut + # short by stop.sound. Shown as cycles after the start. + tunes = {1: sound([[(30, 254, 2), (30, 190, 2)], [(20, 300, 4)], [], [(40, 4, 8)]]), + 2: sound([[(6, 254, 2)]]), + 3: sound([[(120, 254, 2)]]), + 4: sound([[(3, 254 - k, 2) for k in range(20)]]), + 5: sound([[(21, 254, 2)], [(60, 200, 2)], [(90, 180, 2)], [(120, 4, 2)]]), + 6: sound([[], [(60, 200, 2)]])} + # (sound, flag, start cycle, sound on?) + plays = [(1, 60, 3, True), (2, 61, 30, True), (3, 62, 40, True), (4, 63, 90, True), (1, 64, 120, False), + (3, 65, 125, True), (5, 66, 140, True), (6, 67, 160, True)] + stopAt = 130 + showAt = 170 + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 1) + showPic(L) + L.show_pic() + for n in tunes: + L.load_sound(n) + L.increment(V_CYCLE) + for i, (n, flag, start, on) in enumerate(plays): + with L.If(("equaln", V_CYCLE, start)): + if on: + L.set(F_SOUND) + else: + L.reset(F_SOUND) + L.sound(n, flag) + with L.If(("isset", flag), ("equaln", 220 + i, 0)): + L.assignv(220 + i, V_CYCLE) + L.subn(220 + i, start) + with L.If(("equaln", V_CYCLE, stopAt)): + L.stop_sound() + with L.If(("equaln", V_CYCLE, showAt)): + L.display(1, 0, L.m("4 voices %v220 6 ticks %v221")) + L.display(2, 0, L.m("120 ticks %v222 20 notes %v223")) + L.display(3, 0, L.m("sound off %v224 stopped %v225")) + L.display(4, 0, L.m("long tails %v226 no voice 0 %v227")) + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}, sounds=tunes), "wait 10\nshot a\n" + + +def probeTextScreen(): + # The text screen: attributes, display, clear.lines, then back to the + # picture. + def body(L): + with L.If(("isset", F_NEW_ROOM)): + showPic(L) + L.show_pic() + L.status_line_on() + with L.If(("have.key",)): + L.increment(V_CYCLE) + with L.If(("equaln", V_CYCLE, 1)): + L.text_screen() + L.display(2, 2, L.m("Text screen, default colours")) + L.set_text_attribute(14, 1) + L.display(4, 2, L.m("Yellow on blue")) + L.set_text_attribute(0, 15) + L.display(6, 2, L.m("Black on white")) + L.clear_lines(10, 12, 4) + L.set_text_attribute(15, 0) + with L.If(("equaln", V_CYCLE, 2)): + L.graphics() + script = "wait 2\nshot g1\nkey Return\nwait 1\nshot t1\nkey Return\nwait 1\nshot g2\n" + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}), script + + +def probeFont(): + # Every printable character, for reading text in screenshots + # (tests/tools/compareFrames.py --fonts). + rows = ['!"#$&\'()*+,-./0123456789:;<=>?', '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^', '_`abcdefghijklmnopqrstuvwxyz{|', '}~'] + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + for i, text in enumerate(rows): + L.display(i, 0, L.m(text)) + return dict(logics=roomLogic(body)), "wait 3\nshot font\n" + + +def probeShake(): + # shake.screen(1), (2) and (3): the whole screen -- picture, status + # line, displayed text and the input line -- moves and comes back, + # recorded frame by frame (tests/tools/shakeMeasure.py reads it). + pic = bytes([PIC_COLOR, 1, PIC_LINE, 0, 0, 0, 167, PIC_COLOR, 2, PIC_LINE, 159, 0, 159, 167, + PIC_COLOR, 4, PIC_LINE, 0, 0, 159, 0, PIC_COLOR, 5, PIC_LINE, 0, 167, 159, 167, + PIC_COLOR, 0, PIC_LINE, 80, 20, 80, 150, PIC_LINE, 20, 84, 140, 84, + PIC_COLOR, 3, PIC_LINE, 30, 30, 50, 30, 50, 50, 30, 50, 30, 30, PIC_FILL, 40, 40, PIC_END]) + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 1) + showPic(L) + L.load_view(0) + L.animate_obj(0) + L.set_view(0, 0) + L.position(0, 100, 120) + L.stop_cycling(0) + L.draw(0) + L.show_pic() + L.status_line_on() + L.set_string(0, L.m(">")) + L.set_cursor_char(L.m("_")) + L.accept_input() + L.display(22, 0, L.m("row 22 display text")) + L.increment(V_CYCLE) + for when, times in ((20, 1), (40, 2), (60, 3)): + with L.If(("equaln", V_CYCLE, when)): + L.shake_screen(times) + return dict(logics=roomLogic(body), pics={1: pic}, views=VIEWS), "wait 0.5\nrecord shake\nwait 12\nstop\n" + + +def probeWindowEdge(): + # print.at windows at columns 0, 1 and 2 on a coloured picture. Sierra's + # interpreter keeps a window's x in a byte and walks the screen as one + # line of 160-pixel rows, so a box left of the screen (column 0 or 1) + # is saved, filled and put back 256 pixels on -- a row down, 96 in -- + # and runs on into the row below; closing it leaves part of the window + # behind. The texts have spaces where a restore cuts through a cell (an + # interpreter's font shows there only in part). + pic = bytes([PIC_COLOR, 2, PIC_FILL, 80, 80, PIC_COLOR, 1, PIC_LINE, 0, 0, 159, 167, + PIC_LINE, 0, 100, 159, 20, PIC_END]) + cases = [(5, 0, 0, "AAAA"), (5, 1, 0, "BBBB"), (5, 1, 20, "DDDD DDDD DDDD DDDD DDDD DDDD DDDD"), + (5, 2, 0, "CCCC"), (12, 1, 36, "AAAAAAAAA BBBBBBBBB CC DDDDDDDDDDD"), (5, 3, 0, "EEEE"), + (2, 1, 0, "F"), (15, 1, 10, "GGGG GGGG GGGG GGGG GGGG GGGG"), + (5, 0, 30, "HHHHHHHHHHHHHH HHHHHHH HHHHHH HHHH HHHH")] + step = 230 + wait = 231 + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 1) + showPic(L) + L.show_pic() + L.assignn(wait, 20) + with L.If(("greatern", wait, 0)): + L.decrement(wait) + with L.If(("equaln", wait, 0)): + for k, (row, col, width, text) in enumerate(cases): + with L.If(("equaln", step, k)): + showPic(L) + L.show_pic() + L.print_at(L.m(text), row, col, width) + L.assignn(wait, 20) + L.increment(step) + script = "".join("wait 1.5\nshot e%d_open\nkey Return\nwait 0.5\nshot e%d_closed\n" % (k, k) for k in range(len(cases))) + return dict(logics=roomLogic(body), pics={1: pic}), script + + +def probeCursor(): + # The command line's echo when set.cursor.char changes mid-line (F1 + # takes the cursor away, F2 makes it '#', F3 '*'). Sierra's interpreter + # echoes from where it last left off and steps back over the cursor + # before each character whenever there is one -- drawn or not -- so a + # cursor set mid-line overwrites the last character typed, and rubbing + # out can run back over the prompt; a cursor taken away is left on the + # line. + keys = {"F1": (59, 1, ""), "F2": (60, 2, "#"), "F3": (61, 3, "*")} + + def body(L): + with L.If(("isset", F_NEW_ROOM)): + L.assignn(V_CYCLE_DELAY, 1) + showPic(L) + L.show_pic() + L.set_string(0, L.m(">")) + L.set_cursor_char(L.m("_")) + L.accept_input() + for scan, controller, _ in keys.values(): + L.set_key(0, scan, controller) + for _, controller, cursor in keys.values(): + with L.If(("controller", controller)): + L.set_cursor_char(L.m(cursor)) + steps = ["type abc", "c1", "key BackSpace", "c2", "key BackSpace", "c3", "type d", "c4", "key Return", "c5", + "type pq", "c6", "key F1", "type r", "c7", "key BackSpace", "c8", "key BackSpace", "c9", + "key F2", "type st", "d1", "key BackSpace", "d2", "key Return", "d3", "type uv", "d4", + "key F1", "key Return", "d5", "type wx", "d6", "key BackSpace", "d7", + "key F3", "type yz", "d8", "key BackSpace", "d9", "key BackSpace", "e1", "key BackSpace", "e2"] + script = "wait 2\n" + "".join(("%s\nwait 0.5\n" % st) if " " in st else ("shot %s\n" % st) for st in steps) + return dict(logics=roomLogic(body), pics={1: EMPTY_PIC}), script + + +PROBES = { + "font": probeFont, + "pictures": probePictures, + "windows": probeWindows, + "timing": probeTiming, + "motion": probeMotion, + "counters": probeCounters, + "redraw": probeRedraw, + "setview": probeSetView, + "control": probeControl, + "collide": probeCollide, + "priority": probePriority, + "follow": probeFollow, + "ego": probeEgo, + "egodir": probeEgoDirection, + "steering": probeSteering, + "cycling": probeCycling, + "clock": probeClock, + "parser": probeParser, + "sound": probeSound, + "textscreen": probeTextScreen, + "shake": probeShake, + "winedge": probeWindowEdge, + "cursor": probeCursor, +} + + +def build(outDir, names): + for name in names: + resources, script = PROBES[name]() + path = os.path.join(outDir, name) + writeGame(path, **resources) + with open(os.path.join(path, "probe.scr"), "w") as f: + f.write(script) + + +if __name__ == "__main__": + if len(sys.argv) >= 2 and sys.argv[1] == "list": + print("\n".join(PROBES)) + elif len(sys.argv) >= 3 and sys.argv[1] == "build": + build(sys.argv[2], sys.argv[3:] or list(PROBES)) + else: + sys.exit("usage: probes.py build OUTDIR [NAME...] | probes.py list") diff --git a/tests/testAgiPic.c b/tests/testAgiPic.c deleted file mode 100644 index 5aeed38..0000000 --- a/tests/testAgiPic.c +++ /dev/null @@ -1,191 +0,0 @@ -// Host-side test for agiPic.c. -// -// Loads a single PIC resource from a game directory, decodes it, and -// writes two image files: a color PPM for the visual plane (palette -// expanded to 8-bit RGB) and a grayscale PGM for the priority plane. -// Also reports the byte count and pixel coverage so a regression in -// the decoder shows up as a numeric diff. -// -// Usage: -// testAgiPic [pic-id] -// -// The default pic-id is the first non-empty entry in PICDIR; KQ3 has -// id 0 unused so the loader hops forward until it finds one. -// -// Exit codes: -// 0 decoded; outputs written -// 1 game directory missing, PIC slot empty, or decode failed -// 2 invalid command line - -#include "agi.h" - -#include -#include -#include - - -// 4-bit channel -> 8-bit channel scaling: x * 17 maps 0..15 to 0..255 -// without bias (15 * 17 = 255 exactly). -#define CHANNEL_SCALE 17 - - -// ----- Prototypes ----- - -static int16_t firstNonEmptyPic(const AgiGameT *game); -static bool writePgm(const char *path, const uint8_t *plane); -static bool writePpm(const char *path, const uint8_t *plane); - - -// ----- Helpers (alphabetical) ----- - -static int16_t firstNonEmptyPic(const AgiGameT *game) { - uint16_t count; - uint16_t i; - - count = game->resCount[AGI_RES_PIC]; - for (i = 0u; i < count; i++) { - if (game->resDir[AGI_RES_PIC][i].volume < AGI_MAX_VOLUMES) { - return (int16_t)i; - } - } - return -1; -} - - -static bool writePgm(const char *path, const uint8_t *plane) { - FILE *fp; - uint32_t i; - uint8_t gray; - - fp = fopen(path, "wb"); - if (fp == NULL) { - return false; - } - fprintf(fp, "P5\n%d %d\n255\n", AGI_PIC_WIDTH, AGI_PIC_HEIGHT); - for (i = 0u; i < (uint32_t)AGI_PIC_PIXELS; i++) { - gray = (uint8_t)((plane[i] & 0x0Fu) * CHANNEL_SCALE); - fputc(gray, fp); - } - fclose(fp); - return true; -} - - -static bool writePpm(const char *path, const uint8_t *plane) { - FILE *fp; - uint32_t i; - uint16_t rgb; - uint8_t r; - uint8_t g; - uint8_t b; - - fp = fopen(path, "wb"); - if (fp == NULL) { - return false; - } - fprintf(fp, "P6\n%d %d\n255\n", AGI_PIC_WIDTH, AGI_PIC_HEIGHT); - for (i = 0u; i < (uint32_t)AGI_PIC_PIXELS; i++) { - rgb = kAgiPalette[plane[i] & 0x0Fu]; - r = (uint8_t)(((rgb >> 8) & 0x0Fu) * CHANNEL_SCALE); - g = (uint8_t)(((rgb >> 4) & 0x0Fu) * CHANNEL_SCALE); - b = (uint8_t)((rgb & 0x0Fu) * CHANNEL_SCALE); - fputc(r, fp); - fputc(g, fp); - fputc(b, fp); - } - fclose(fp); - return true; -} - - -// ----- main ----- - -int main(int argc, char **argv) { - AgiGameT game; - AgiPicT pic; - int16_t picId; - uint8_t *bytes; - uint16_t length; - uint32_t nonBgVisual; - uint32_t nonBgPriority; - uint32_t i; - char visPath[256]; - char priPath[256]; - - if (argc < 2 || argc > 3) { - fprintf(stderr, "usage: %s [pic-id]\n", argv[0]); - return 2; - } - - if (!agiResOpen(&game)) { - fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); - return 1; - } - - if (argc == 3) { - picId = (int16_t)atoi(argv[2]); - } else { - picId = firstNonEmptyPic(&game); - } - if (picId < 0 || (uint16_t)picId >= game.resCount[AGI_RES_PIC]) { - fprintf(stderr, "FAIL: no PIC at id %d\n", (int)picId); - agiResClose(&game); - return 1; - } - - bytes = agiResLoad(&game, AGI_RES_PIC, (uint16_t)picId, &length); - if (bytes == NULL) { - fprintf(stderr, "FAIL: PIC %d failed to load\n", (int)picId); - agiResClose(&game); - return 1; - } - - if (!agiPicAlloc(&pic)) { - fprintf(stderr, "FAIL: agiPicAlloc out of memory\n"); - free(bytes); - agiResClose(&game); - return 1; - } - agiPicClear(&pic); - - if (!agiPicDecode(&pic, bytes, length)) { - fprintf(stderr, "FAIL: PIC %d decode rejected (malformed stream?)\n", (int)picId); - agiPicFree(&pic); - free(bytes); - agiResClose(&game); - return 1; - } - - nonBgVisual = 0u; - nonBgPriority = 0u; - for (i = 0u; i < (uint32_t)AGI_PIC_PIXELS; i++) { - if (pic.visual[i] != AGI_VISUAL_BG) { - nonBgVisual++; - } - if (pic.priority[i] != AGI_PRIORITY_BG) { - nonBgPriority++; - } - } - - snprintf(visPath, sizeof(visPath), "build/tests/pic%d_visual.ppm", (int)picId); - snprintf(priPath, sizeof(priPath), "build/tests/pic%d_priority.pgm", (int)picId); - if (!writePpm(visPath, pic.visual) || !writePgm(priPath, pic.priority)) { - fprintf(stderr, "FAIL: could not write output images\n"); - agiPicFree(&pic); - free(bytes); - agiResClose(&game); - return 1; - } - - printf("PIC %d: %u bytes, visual %u/%u px painted, priority %u/%u px painted\n", - (int)picId, (unsigned)length, - (unsigned)nonBgVisual, (unsigned)AGI_PIC_PIXELS, - (unsigned)nonBgPriority, (unsigned)AGI_PIC_PIXELS); - printf(" wrote %s\n", visPath); - printf(" wrote %s\n", priPath); - - agiPicFree(&pic); - free(bytes); - agiResClose(&game); - return 0; -} diff --git a/tests/testAgiPipeline.c b/tests/testAgiPipeline.c deleted file mode 100644 index b329dda..0000000 --- a/tests/testAgiPipeline.c +++ /dev/null @@ -1,284 +0,0 @@ -// Host-side AGI pipeline test (Phase 1 sub-D). -// -// Wires up a real logic-resource cache + trace callbacks and runs -// the VM in a simulated game-cycle loop: -// * agiVmRun() -// * NEW_ROOM -> update var[0]/var[1], reset flags, restart logic.0 -// * RETURN -> count one cycle, restart logic.0 -// * UNKNOWN_OP -> print and exit -// Capped at MAX_CYCLES so a logic that always returns without -// painting can't loop forever. -// -// What this lets us verify before touching any emulator: -// - logic.0's prologue + IF branching against fresh game state -// - room transition handling (new.room(N) loads logic N) -// - room logic execution (load.pic/draw.pic/show.pic callbacks) -// - call.logic / RETURN frame management -// -// Usage: -// testAgiPipeline [max-cycles] - -#include "agi.h" - -#include -#include -#include - - -#define MAX_CYCLES_DEFAULT 20u -#define AGI_LOGIC_HDR_BYTES 2u - - -typedef struct { - uint8_t *raw; - uint16_t rawLength; - const uint8_t *bytecode; - uint16_t bytecodeLength; -} CachedLogicT; - - -static AgiGameT gGame; -static CachedLogicT gLogicCache[AGI_MAX_RESOURCES]; -static uint32_t gTraceLoadPic; -static uint32_t gTraceDrawPic; -static uint32_t gTraceShowPic; -static uint32_t gTraceDiscardPic; -static uint32_t gTraceOverlayPic; -static uint32_t gTraceFetchLogic; - - -static void cbDiscardPic(void *ctx, uint8_t picId); -static void cbDrawPic(void *ctx, uint8_t picId); -static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength); -static void cbLoadPic(void *ctx, uint8_t picId); -static void cbOverlayPic(void *ctx, uint8_t picId); -static void cbShowPic(void *ctx); -static const char *haltName(AgiVmHaltE r); -static void releaseCache(void); - - -static void cbDiscardPic(void *ctx, uint8_t picId) { - (void)ctx; - gTraceDiscardPic++; - printf(" [host] discardPic(%u)\n", (unsigned)picId); -} - - -static void cbDrawPic(void *ctx, uint8_t picId) { - (void)ctx; - gTraceDrawPic++; - printf(" [host] drawPic(%u)\n", (unsigned)picId); -} - - -static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength) { - CachedLogicT *slot; - uint8_t *raw; - uint16_t rawLength; - uint16_t bcLen; - - (void)ctx; - slot = &gLogicCache[logicId]; - if (slot->bytecode != NULL) { - *outLength = slot->bytecodeLength; - return slot->bytecode; - } - - if (logicId >= gGame.resCount[AGI_RES_LOGIC]) { - return NULL; - } - raw = agiResLoad(&gGame, AGI_RES_LOGIC, (uint16_t)logicId, &rawLength); - if (raw == NULL || rawLength < AGI_LOGIC_HDR_BYTES) { - if (raw != NULL) { - free(raw); - } - return NULL; - } - bcLen = (uint16_t)(raw[0] | ((uint16_t)raw[1] << 8)); - if ((uint16_t)(AGI_LOGIC_HDR_BYTES + bcLen) > rawLength) { - free(raw); - return NULL; - } - slot->raw = raw; - slot->rawLength = rawLength; - slot->bytecode = raw + AGI_LOGIC_HDR_BYTES; - slot->bytecodeLength = bcLen; - gTraceFetchLogic++; - printf(" [host] fetchLogic(%u) -> %u bytes (first load)\n", - (unsigned)logicId, (unsigned)bcLen); - - *outLength = bcLen; - return slot->bytecode; -} - - -static void cbLoadPic(void *ctx, uint8_t picId) { - (void)ctx; - gTraceLoadPic++; - printf(" [host] loadPic(%u)\n", (unsigned)picId); -} - - -static void cbOverlayPic(void *ctx, uint8_t picId) { - (void)ctx; - gTraceOverlayPic++; - printf(" [host] overlayPic(%u)\n", (unsigned)picId); -} - - -static void cbShowPic(void *ctx) { - (void)ctx; - gTraceShowPic++; - printf(" [host] showPic\n"); -} - - -static const char *haltName(AgiVmHaltE r) { - switch (r) { - case AGI_VM_HALT_NONE: return "NONE"; - case AGI_VM_HALT_RETURN: return "RETURN"; - case AGI_VM_HALT_UNKNOWN_OP: return "UNKNOWN_OP"; - case AGI_VM_HALT_TRUNCATED: return "TRUNCATED"; - case AGI_VM_HALT_NEW_ROOM: return "NEW_ROOM"; - case AGI_VM_HALT_NO_LOGIC: return "NO_LOGIC"; - case AGI_VM_HALT_STACK_OVER: return "STACK_OVER"; - default: return "?"; - } -} - - -static void releaseCache(void) { - uint16_t i; - - for (i = 0u; i < AGI_MAX_RESOURCES; i++) { - if (gLogicCache[i].raw != NULL) { - free(gLogicCache[i].raw); - gLogicCache[i].raw = NULL; - } - } -} - - -int main(int argc, char **argv) { - AgiVmT vm; - AgiVmCallbacksT cb; - uint16_t length; - const uint8_t *logic0; - uint32_t maxCycles; - uint32_t cycles; - AgiVmHaltE reason; - uint8_t prevRoom; - uint8_t i; - - if (argc < 2 || argc > 3) { - fprintf(stderr, "usage: %s [max-cycles]\n", argv[0]); - return 2; - } - maxCycles = (argc == 3) ? (uint32_t)atoi(argv[2]) : (uint32_t)MAX_CYCLES_DEFAULT; - if (maxCycles == 0u) { - maxCycles = (uint32_t)MAX_CYCLES_DEFAULT; - } - - if (!agiResOpen(&gGame)) { - fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); - return 1; - } - memset(gLogicCache, 0, sizeof(gLogicCache)); - - cb.fetchLogic = cbFetchLogic; - cb.loadPic = cbLoadPic; - cb.drawPic = cbDrawPic; - cb.showPic = cbShowPic; - cb.discardPic = cbDiscardPic; - cb.overlayPic = cbOverlayPic; - cb.haveKey = NULL; - cb.fetchMessage = NULL; - cb.addToPic = NULL; - cb.soundDuration = NULL; - cb.playSound = NULL; - cb.stopSound = NULL; - cb.ctx = NULL; - - agiVmInit(&vm); - agiVmSetCallbacks(&vm, &cb); - - length = 0u; - logic0 = cbFetchLogic(NULL, 0u, &length); - if (logic0 == NULL) { - fprintf(stderr, "FAIL: could not load logic.0\n"); - releaseCache(); - agiResClose(&gGame); - return 1; - } - agiVmResetToLogic(&vm, logic0, length, 0u); - - cycles = 0u; - for (;;) { - if (cycles >= maxCycles) { - printf(" (capped at %u cycles)\n", (unsigned)maxCycles); - break; - } - cycles++; - // Simulate one wall-clock second per cycle so per-second - // game logic (countdowns, state machines, animation cadence) - // advances under the host test without waiting in real time. - // KQ3's title state machine in particular is gated entirely - // on var 11 changing; without this tick logic.45's countdowns - // never fire and the title appears to stall at 20+ cycles. - agiVmTickSeconds(&vm, 1u); - printf(" cycle %u: pc=%u/%u logic=%u callDepth=%u\n", - (unsigned)cycles, (unsigned)vm.pc, (unsigned)vm.codeLength, - (unsigned)vm.currentLogicId, (unsigned)vm.callDepth); - - reason = agiVmRun(&vm); - - printf(" halt=%s at pc=%u logic=%u", - haltName(reason), (unsigned)vm.pc, (unsigned)vm.currentLogicId); - if (reason == AGI_VM_HALT_UNKNOWN_OP) { - printf(" opcode=0x%02X", (unsigned)vm.lastUnknownOp); - } - if (reason == AGI_VM_HALT_NEW_ROOM) { - printf(" newRoom=%u", (unsigned)vm.newRoomId); - } - printf("\n"); - - if (reason == AGI_VM_HALT_NEW_ROOM) { - prevRoom = vm.vars[0]; - vm.vars[0] = vm.newRoomId; - vm.vars[1] = prevRoom; - // Reset most flags, then set flag 5 ("new room") to - // signal the room logic that its init branch should - // fire. In the real interpreter the engine also resets - // a handful of cycle vars (input, signal, etc.) but for - // the sub-D pipeline test the new-room flag is the only - // one room logic actually checks. - for (i = 0u; i < 32u; i++) { - vm.flags[i] = 0u; - } - vm.flags[5] = 1u; - agiVmResetToLogic(&vm, logic0, length, 0u); - continue; - } - if (reason == AGI_VM_HALT_RETURN) { - // End-of-cycle housekeeping the real interpreter would - // perform: clear flag 5 (new.room) so subsequent cycles - // don't re-run the room-init branch. - vm.flags[5] = 0u; - agiVmResetToLogic(&vm, logic0, length, 0u); - continue; - } - break; - } - - printf("===== trace =====\n"); - printf(" fetchLogic: %u (first-loads only)\n", (unsigned)gTraceFetchLogic); - printf(" loadPic: %u\n", (unsigned)gTraceLoadPic); - printf(" drawPic: %u\n", (unsigned)gTraceDrawPic); - printf(" showPic: %u\n", (unsigned)gTraceShowPic); - printf(" discardPic: %u\n", (unsigned)gTraceDiscardPic); - printf(" overlayPic: %u\n", (unsigned)gTraceOverlayPic); - - releaseCache(); - agiResClose(&gGame); - return 0; -} diff --git a/tests/testAgiRes.c b/tests/testAgiRes.c deleted file mode 100644 index 7776fef..0000000 --- a/tests/testAgiRes.c +++ /dev/null @@ -1,101 +0,0 @@ -// Host-side regression test for agiRes.c. -// -// The AGI resource loader is plain stdio C with no JoeyLib runtime -// dependencies, so we can compile and exercise it on the build host -// without touching any emulator. This keeps the Phase 0 iteration -// loop fast: edit agiRes.c, rerun scripts/test-agi.sh, get a pass -// or fail against KQ3 (or any AGI v2 directory) in under a second. -// -// Usage: testAgiRes -// -// Exit codes: -// 0 resource directories parsed and at least one resource per -// type loaded successfully (or the type was empty) -// 1 game directory missing or malformed -// 2 invalid command line - -#include "agi.h" - -#include -#include -#include - - -// ----- Prototypes ----- - -static const char *resTypeName(AgiResTypeE type); -static int testFirstResource(AgiGameT *game, AgiResTypeE type); - - -// ----- Helpers (alphabetical) ----- - -static const char *resTypeName(AgiResTypeE type) { - switch (type) { - case AGI_RES_LOGIC: return "LOGIC"; - case AGI_RES_PIC: return "PIC "; - case AGI_RES_VIEW: return "VIEW "; - case AGI_RES_SOUND: return "SOUND"; - default: return "?????"; - } -} - - -// Locate the first non-empty resource of `type`, load it, print its -// length. Returns 0 on success or empty type, 1 on load failure. -static int testFirstResource(AgiGameT *game, AgiResTypeE type) { - uint16_t i; - uint16_t length; - uint8_t *payload; - uint16_t count; - - count = game->resCount[type]; - for (i = 0; i < count; i++) { - if (game->resDir[type][i].volume >= AGI_MAX_VOLUMES) { - continue; - } - payload = agiResLoad(game, type, i, &length); - if (payload == NULL) { - fprintf(stderr, "FAIL: %s id %u failed to load\n", resTypeName(type), (unsigned)i); - return 1; - } - printf(" %s: %u resources, first non-empty id %u is %u bytes\n", - resTypeName(type), (unsigned)count, (unsigned)i, (unsigned)length); - free(payload); - return 0; - } - printf(" %s: %u directory slots, all empty\n", resTypeName(type), (unsigned)count); - return 0; -} - - -// ----- main ----- - -int main(int argc, char **argv) { - AgiGameT game; - uint8_t t; - int rc; - - if (argc != 2) { - fprintf(stderr, "usage: %s \n", argv[0]); - return 2; - } - - printf("Opening AGI game directory: %s\n", argv[1]); - if (!agiResOpen(&game)) { - fprintf(stderr, "FAIL: agiResOpen rejected '%s' (missing files? wrong format?)\n", argv[1]); - return 1; - } - - rc = 0; - for (t = 0; t < AGI_RES_COUNT; t++) { - if (testFirstResource(&game, (AgiResTypeE)t) != 0) { - rc = 1; - } - } - - agiResClose(&game); - if (rc == 0) { - printf("OK\n"); - } - return rc; -} diff --git a/tests/testAgiView.c b/tests/testAgiView.c deleted file mode 100644 index 74c3ad4..0000000 --- a/tests/testAgiView.c +++ /dev/null @@ -1,198 +0,0 @@ -// Host-side test for agiView.c. -// -// Loads a single VIEW resource, parses it, dumps the loop/cel -// structure, and writes cel 0 of every loop as a PPM image. The -// transparent color is rendered as bright magenta so the cel's -// silhouette stands out from the actual painted pixels. -// -// Usage: -// testAgiView [view-id] -// -// Exit codes: -// 0 parsed; outputs written -// 1 game directory missing, view slot empty, or parse failed -// 2 invalid command line - -#include "agi.h" - -#include -#include -#include - - -#define CHANNEL_SCALE 17u - -// Pseudo-color used for "transparent" pixels in the output image. -#define TRANS_R 0xFFu -#define TRANS_G 0x00u -#define TRANS_B 0xFFu - - -// ----- Prototypes ----- - -static int16_t firstNonEmptyView(const AgiGameT *game); -static bool writeCelPpm(const char *path, const AgiCelInfoT *cel); - - -// ----- Helpers (alphabetical) ----- - -static int16_t firstNonEmptyView(const AgiGameT *game) { - uint16_t count; - uint16_t i; - - count = game->resCount[AGI_RES_VIEW]; - for (i = 0u; i < count; i++) { - if (game->resDir[AGI_RES_VIEW][i].volume < AGI_MAX_VOLUMES) { - return (int16_t)i; - } - } - return -1; -} - - -// Walk the cel's RLE data, paint into a chunky 8bpp buffer, then -// emit a PPM with transparent pixels mapped to magenta. Same RLE -// rules as agiViewDraw: each byte = (color << 4) | runLen, runLen == 0 -// ends the row. -static bool writeCelPpm(const char *path, const AgiCelInfoT *cel) { - FILE *fp; - uint8_t *buf; - uint16_t w; - uint16_t h; - uint16_t cx; - uint16_t cy; - uint16_t i; - const uint8_t *rle; - uint8_t rleByte; - uint8_t color; - uint8_t runLen; - uint8_t r; - uint8_t transparent; - uint16_t rgb; - - w = (uint16_t)cel->width; - h = (uint16_t)cel->height; - if (w == 0u || h == 0u) { - return false; - } - - buf = (uint8_t *)malloc((size_t)w * (size_t)h); - if (buf == NULL) { - return false; - } - transparent = cel->transparentColor; - memset(buf, transparent, (size_t)w * (size_t)h); - - rle = cel->rleData; - for (cy = 0u; cy < h; cy++) { - cx = 0u; - for (;;) { - rleByte = *rle++; - if (rleByte == 0u) { - break; - } - color = (uint8_t)(rleByte >> 4); - runLen = (uint8_t)(rleByte & 0x0Fu); - for (r = 0u; r < runLen; r++) { - if (cx >= w) { - break; - } - buf[cy * w + cx] = color; - cx++; - } - } - } - - fp = fopen(path, "wb"); - if (fp == NULL) { - free(buf); - return false; - } - fprintf(fp, "P6\n%u %u\n255\n", (unsigned)w, (unsigned)h); - for (i = 0u; i < (uint16_t)(w * h); i++) { - if (buf[i] == transparent) { - fputc(TRANS_R, fp); - fputc(TRANS_G, fp); - fputc(TRANS_B, fp); - } else { - rgb = kAgiPalette[buf[i] & 0x0Fu]; - fputc((int)(((rgb >> 8) & 0x0Fu) * CHANNEL_SCALE), fp); - fputc((int)(((rgb >> 4) & 0x0Fu) * CHANNEL_SCALE), fp); - fputc((int)((rgb & 0x0Fu) * CHANNEL_SCALE), fp); - } - } - fclose(fp); - free(buf); - return true; -} - - -// ----- main ----- - -int main(int argc, char **argv) { - AgiGameT game; - AgiViewT view; - int16_t viewId; - uint8_t *bytes; - uint16_t length; - uint8_t li; - uint8_t ci; - const AgiCelInfoT *cel; - char path[256]; - - if (argc < 2 || argc > 3) { - fprintf(stderr, "usage: %s [view-id]\n", argv[0]); - return 2; - } - - if (!agiResOpen(&game)) { - fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); - return 1; - } - - if (argc == 3) { - viewId = (int16_t)atoi(argv[2]); - } else { - viewId = firstNonEmptyView(&game); - } - if (viewId < 0 || (uint16_t)viewId >= game.resCount[AGI_RES_VIEW]) { - fprintf(stderr, "FAIL: no VIEW at id %d\n", (int)viewId); - agiResClose(&game); - return 1; - } - - bytes = agiResLoad(&game, AGI_RES_VIEW, (uint16_t)viewId, &length); - if (bytes == NULL) { - fprintf(stderr, "FAIL: VIEW %d failed to load\n", (int)viewId); - agiResClose(&game); - return 1; - } - - if (!agiViewParse(&view, bytes, length)) { - fprintf(stderr, "FAIL: VIEW %d parse rejected\n", (int)viewId); - agiResClose(&game); - return 1; - } - - printf("VIEW %d: %u bytes, %u loops\n", (int)viewId, (unsigned)length, (unsigned)view.loopCount); - for (li = 0u; li < view.loopCount; li++) { - printf(" loop %u: %u cels\n", (unsigned)li, (unsigned)view.loops[li].celCount); - for (ci = 0u; ci < view.loops[li].celCount; ci++) { - cel = &view.loops[li].cels[ci]; - printf(" cel %u: %ux%u trans=%u mirror=%u (src loop %u)\n", - (unsigned)ci, (unsigned)cel->width, (unsigned)cel->height, - (unsigned)cel->transparentColor, - (unsigned)cel->mirrored, (unsigned)cel->mirrorSourceLoop); - } - if (view.loops[li].celCount > 0u) { - snprintf(path, sizeof(path), "build/tests/view%d_loop%u_cel0.ppm", (int)viewId, (unsigned)li); - if (!writeCelPpm(path, &view.loops[li].cels[0])) { - fprintf(stderr, " warn: could not write %s\n", path); - } - } - } - - agiViewFree(&view); - agiResClose(&game); - return 0; -} diff --git a/tests/testAgiVm.c b/tests/testAgiVm.c deleted file mode 100644 index 330403c..0000000 --- a/tests/testAgiVm.c +++ /dev/null @@ -1,135 +0,0 @@ -// Host-side test for agiVm.c (Phase 1 sub-A). -// -// Loads a LOGIC resource (default 0), runs the VM until it halts, -// and prints the halt reason plus any non-zero vars/flags. Since -// only the arithmetic and flag opcodes (0x00-0x11) are implemented -// this pass, expectation is one of: -// - HALT_RETURN at offset N (logic was entirely sub-A opcodes) -// - HALT_UNKNOWN_OP 0xXX at offset N (hit IF / new.room / etc.) -// - HALT_TRUNCATED at offset N (malformed resource) -// -// Usage: -// testAgiVm [logic-id] - -#include "agi.h" - -#include -#include - - -// ----- Prototypes ----- - -static const char *haltName(AgiVmHaltE r); -static void printNonZeroState(const AgiVmT *vm); - - -// ----- Helpers (alphabetical) ----- - -static const char *haltName(AgiVmHaltE r) { - switch (r) { - case AGI_VM_HALT_NONE: return "NONE"; - case AGI_VM_HALT_RETURN: return "RETURN"; - case AGI_VM_HALT_UNKNOWN_OP: return "UNKNOWN_OP"; - case AGI_VM_HALT_TRUNCATED: return "TRUNCATED"; - case AGI_VM_HALT_NEW_ROOM: return "NEW_ROOM"; - default: return "?"; - } -} - - -static void printNonZeroState(const AgiVmT *vm) { - uint16_t i; - uint16_t nonZeroVars; - uint16_t nonZeroFlags; - - nonZeroVars = 0u; - nonZeroFlags = 0u; - for (i = 0u; i < AGI_VM_NUM_VARS; i++) { - if (vm->vars[i] != 0u) { - if (nonZeroVars == 0u) { - printf(" vars:"); - } - printf(" v%u=%u", (unsigned)i, (unsigned)vm->vars[i]); - nonZeroVars++; - } - } - if (nonZeroVars > 0u) { - printf("\n"); - } - for (i = 0u; i < AGI_VM_NUM_FLAGS; i++) { - if (vm->flags[i] != 0u) { - if (nonZeroFlags == 0u) { - printf(" flags:"); - } - printf(" f%u", (unsigned)i); - nonZeroFlags++; - } - } - if (nonZeroFlags > 0u) { - printf("\n"); - } -} - - -// ----- main ----- - -int main(int argc, char **argv) { - AgiGameT game; - AgiVmT vm; - uint16_t logicId; - uint8_t *bytes; - uint16_t length; - AgiVmHaltE reason; - - if (argc < 2 || argc > 3) { - fprintf(stderr, "usage: %s [logic-id]\n", argv[0]); - return 2; - } - - if (!agiResOpen(&game)) { - fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); - return 1; - } - - logicId = (argc == 3) ? (uint16_t)atoi(argv[2]) : 0u; - if (logicId >= game.resCount[AGI_RES_LOGIC]) { - fprintf(stderr, "FAIL: no LOGIC at id %u\n", (unsigned)logicId); - agiResClose(&game); - return 1; - } - - bytes = agiResLoad(&game, AGI_RES_LOGIC, logicId, &length); - if (bytes == NULL) { - fprintf(stderr, "FAIL: LOGIC %u failed to load\n", (unsigned)logicId); - agiResClose(&game); - return 1; - } - - agiVmInit(&vm); - if (!agiVmLoadLogic(&vm, bytes, length)) { - fprintf(stderr, "FAIL: agiVmLoadLogic rejected LOGIC %u (truncated header?)\n", (unsigned)logicId); - free(bytes); - agiResClose(&game); - return 1; - } - - printf("LOGIC %u: %u bytes resource, %u bytes bytecode\n", - (unsigned)logicId, (unsigned)length, (unsigned)vm.codeLength); - - reason = agiVmRun(&vm); - - printf(" halt=%s at pc=%u/%u", - haltName(reason), (unsigned)vm.pc, (unsigned)vm.codeLength); - if (reason == AGI_VM_HALT_UNKNOWN_OP) { - printf(" opcode=0x%02X", (unsigned)vm.lastUnknownOp); - } - if (reason == AGI_VM_HALT_NEW_ROOM) { - printf(" newRoom=%u", (unsigned)vm.newRoomId); - } - printf("\n"); - printNonZeroState(&vm); - - free(bytes); - agiResClose(&game); - return 0; -} diff --git a/tests/testResources.c b/tests/testResources.c new file mode 100644 index 0000000..aafb07f --- /dev/null +++ b/tests/testResources.c @@ -0,0 +1,233 @@ +// testResources: every resource of the game in DATA/ loads and decodes. +// +// Prints the interpreter version the game's files name, then one line per +// resource to stdout -- a picture's visual and priority screens as FNV-1a +// hashes, a view's loop and cel counts with a hash of its decoded cels, a +// logic's code and message counts, a sound's note counts -- for +// scripts/test-agi.sh to compare, and a summary line on stderr. Exits 1 +// when a resource is malformed. +// +// Run on the host (BLANK port) from a folder holding DATA/. + +#include "agi.h" + +#include +#include +#include + +#include "joey/core.h" + + +#define FNV_OFFSET_BASIS 2166136261u +#define FNV_PRIME 16777619u +#define LOGIC_HEADER_BYTES 2u +#define SOUND_VOICES 4u +#define SOUND_NOTE_BYTES 5u +#define SOUND_END_BYTE 0xFFu +#define RLE_COLOR_SHIFT 4u +#define RLE_COUNT_MASK 0x0Fu +#define MAX_CEL_PIXELS (255u * 255u) + + +// ----- Prototypes ----- + +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 testView(uint16_t n, uint8_t *pixels); + + +// ----- Internal helpers (alphabetical) ----- + +static uint32_t fnv(uint32_t hash, const uint8_t *p, uint32_t length) { + uint32_t i; + + for (i = 0u; i < length; i++) { + hash = (hash ^ p[i]) * FNV_PRIME; + } + return hash; +} + + +static bool testLogic(uint16_t n) { + const uint8_t *raw; + uint16_t codeLen; + + if (!vmLoadLogic((uint8_t)n)) { + fprintf(stderr, "logic %u: does not load\n", (unsigned)n); + return false; + } + raw = gAgi->logicRaw[n]; + codeLen = (uint16_t)(raw[0] | ((uint16_t)raw[1] << 8)); + printf("logic %u code %u messages %u\n", (unsigned)n, (unsigned)codeLen, (unsigned)raw[LOGIC_HEADER_BYTES + codeLen]); + return true; +} + + +static bool testPicture(uint16_t n, uint8_t *visual, uint8_t *priority) { + uint8_t *data; + uint16_t length; + + data = agiResLoad(&gAgi->game, AGI_RES_PIC, n, &length); + if (data == NULL) { + fprintf(stderr, "picture %u: does not load\n", (unsigned)n); + return false; + } + memset(visual, AGI_PIC_BG_COLOR, AGI_PIXELS); + memset(priority, AGI_PIC_BG_PRIORITY, AGI_PIXELS); + if (!agiPicDecode(visual, priority, data, length)) { + fprintf(stderr, "picture %u: does not decode\n", (unsigned)n); + jlFree(data); + return false; + } + jlFree(data); + printf("picture %u visual %08x priority %08x\n", (unsigned)n, + (unsigned)fnv(FNV_OFFSET_BASIS, visual, AGI_PIXELS), (unsigned)fnv(FNV_OFFSET_BASIS, priority, AGI_PIXELS)); + return true; +} + + +// Every voice's notes must end (FF FF) inside the resource. +static bool testSound(uint16_t n) { + uint8_t *data; + uint16_t length; + uint16_t pos; + uint16_t notes[SOUND_VOICES]; + uint8_t v; + + data = agiResLoad(&gAgi->game, AGI_RES_SOUND, n, &length); + if (data == NULL || length < SOUND_VOICES * 2u) { + fprintf(stderr, "sound %u: does not load\n", (unsigned)n); + jlFree(data); + return false; + } + for (v = 0u; v < SOUND_VOICES; v++) { + pos = (uint16_t)(data[v * 2u] | ((uint16_t)data[v * 2u + 1u] << 8)); + notes[v] = 0u; + while ((uint32_t)pos + 1u < length && !(data[pos] == SOUND_END_BYTE && data[pos + 1u] == SOUND_END_BYTE)) { + pos = (uint16_t)(pos + SOUND_NOTE_BYTES); + 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; + } + } + 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]); + return true; +} + + +// Decode every cel's runs: no row may run past the cel's width or the +// resource's end. +static bool testView(uint16_t n, uint8_t *pixels) { + const AgiViewT *view; + const AgiCelT *c; + const uint8_t *rle; + const uint8_t *end; + uint32_t hash; + uint16_t cels; + uint8_t loop; + uint8_t i; + uint8_t row; + uint8_t x; + uint8_t run; + + view = agiViewGet((uint8_t)n); + if (view == NULL) { + fprintf(stderr, "view %u: does not load\n", (unsigned)n); + return false; + } + end = view->raw + view->rawLength; + hash = FNV_OFFSET_BASIS; + cels = 0u; + for (loop = 0u; loop < view->loopCount; loop++) { + for (i = 0u; i < view->loops[loop].celCount; i++) { + c = &view->loops[loop].cels[i]; + rle = c->rle; + memset(pixels, c->transparent, (size_t)c->width * c->height); + for (row = 0u; row < c->height; row++) { + x = 0u; + while (rle < end && *rle != 0u) { + run = (uint8_t)(*rle & RLE_COUNT_MASK); + if ((uint16_t)x + run > c->width) { + fprintf(stderr, "view %u loop %u cel %u: row %u is too wide\n", (unsigned)n, (unsigned)loop, (unsigned)i, (unsigned)row); + return false; + } + memset(&pixels[(uint16_t)row * c->width + x], *rle >> RLE_COLOR_SHIFT, run); + x = (uint8_t)(x + run); + rle++; + } + if (rle >= end) { + fprintf(stderr, "view %u loop %u cel %u: runs off the end\n", (unsigned)n, (unsigned)loop, (unsigned)i); + return false; + } + rle++; + } + hash = fnv(hash, pixels, (uint32_t)c->width * c->height); + cels++; + } + } + printf("view %u loops %u cels %u pixels %08x\n", (unsigned)n, (unsigned)view->loopCount, (unsigned)cels, (unsigned)hash); + return true; +} + + +// ----- Entry point ----- + +int main(void) { + jlConfigT cfg; + uint8_t *visual; + uint8_t *priority; + uint8_t *pixels; + uint16_t n; + uint16_t counts[AGI_RES_COUNT]; + bool ok; + + memset(&cfg, 0, sizeof(cfg)); + if (!jlInit(&cfg)) { + fprintf(stderr, "jlInit failed\n"); + return 1; + } + gAgi = (AgiStateT *)calloc(1u, sizeof(AgiStateT)); + if (gAgi == NULL || !agiResOpen(&gAgi->game)) { + fprintf(stderr, "no game in DATA/\n"); + return 1; + } + // The Sierra interpreter version the game's files name (its quirks + // follow it). + printf("interpreter %u\n", (unsigned)gAgi->game.interpreterVersion); + visual = (uint8_t *)malloc(AGI_PIXELS); + priority = (uint8_t *)malloc(AGI_PIXELS); + pixels = (uint8_t *)malloc(MAX_CEL_PIXELS); + if (visual == NULL || priority == NULL || pixels == NULL) { + fprintf(stderr, "out of memory\n"); + return 1; + } + memset(counts, 0, sizeof(counts)); + ok = true; + for (n = 0u; n < AGI_MAX_RESOURCES; n++) { + if (n < gAgi->game.resCount[AGI_RES_LOGIC] && gAgi->game.resDir[AGI_RES_LOGIC][n].volume < AGI_MAX_VOLUMES) { + ok = testLogic(n) && ok; + counts[AGI_RES_LOGIC]++; + } + if (n < gAgi->game.resCount[AGI_RES_PIC] && gAgi->game.resDir[AGI_RES_PIC][n].volume < AGI_MAX_VOLUMES) { + ok = testPicture(n, visual, priority) && ok; + counts[AGI_RES_PIC]++; + } + if (n < gAgi->game.resCount[AGI_RES_VIEW] && gAgi->game.resDir[AGI_RES_VIEW][n].volume < AGI_MAX_VOLUMES) { + ok = testView(n, pixels) && ok; + counts[AGI_RES_VIEW]++; + } + if (n < gAgi->game.resCount[AGI_RES_SOUND] && gAgi->game.resDir[AGI_RES_SOUND][n].volume < AGI_MAX_VOLUMES) { + ok = testSound(n) && ok; + counts[AGI_RES_SOUND]++; + } + } + fprintf(stderr, "%u logics, %u pictures, %u views, %u sounds%s\n", (unsigned)counts[AGI_RES_LOGIC], (unsigned)counts[AGI_RES_PIC], + (unsigned)counts[AGI_RES_VIEW], (unsigned)counts[AGI_RES_SOUND], ok ? "" : " -- MALFORMED (see above)"); + return ok ? 0 : 1; +} diff --git a/tests/tools/compareFrames.py b/tests/tools/compareFrames.py new file mode 100755 index 0000000..c347313 --- /dev/null +++ b/tests/tools/compareFrames.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# compareFrames.py - compare a frame from Sierra's interpreter with one +# from JoeyAGI. Both are mapped to the 16 EGA colours, then compared one +# 8x8 text cell at a time. JoeyAGI draws text in its own font, so a cell +# where both frames hold a character -- read with each interpreter's font, +# captured from the "font" probe -- counts as equal when the characters +# and colours match. Every other differing pixel is a real difference. +# +# Sierra shows its text screen (text.screen, the inventory) in the PC's +# 40-column text mode, which DOSBox captures at 360x400 (9x16 cells). +# JoeyAGI draws the same screen on the bitmap, so those frames are +# compared cell by cell: blank or not, and the colours. +# +# compareFrames.py ORACLE JOEY [DIFF.png] [--fonts ORACLEFONT JOEYFONT] +# prints "real=N text=M" and the cells that differ; exit 1 if any do. +import sys +from collections import Counter + +from PIL import Image + +EGA = [(0, 0, 0), (0, 0, 170), (0, 170, 0), (0, 170, 170), (170, 0, 0), (170, 0, 170), (170, 85, 0), + (170, 170, 170), (85, 85, 85), (85, 85, 255), (85, 255, 85), (85, 255, 255), (255, 85, 85), + (255, 85, 255), (255, 255, 85), (255, 255, 255)] +WIDTH = 320 +HEIGHT = 200 +TEXT_MODE_SIZE = (360, 400) +TEXT_CELL_W = 9 +TEXT_CELL_H = 16 +CELL = 8 +COLS = WIDTH // CELL +ROWS = HEIGHT // CELL +WINDOW_FRAME = 4 +WINDOW_BG = 15 +WINDOW_MIN_RUN = 16 +UNKNOWN = "?" +# The font probe displays these rows from row 0, column 0. +FONT_ROWS = ['!"#$&\'()*+,-./0123456789:;<=>?', '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^', '_`abcdefghijklmnopqrstuvwxyz{|', '}~'] + +_nearest = {} + + +def isTextMode(path): + return Image.open(path).size == TEXT_MODE_SIZE + + +def egaGrid(im): + px = im.load() + grid = [] + for y in range(im.size[1]): + row = [] + for x in range(im.size[0]): + c = px[x, y] + if c not in _nearest: + _nearest[c] = min(range(16), key=lambda i: sum((a - b) ** 2 for a, b in zip(EGA[i], c))) + row.append(_nearest[c]) + grid.append(row) + return grid + + +def load(path): + # A frame as rows of EGA colour numbers, scaled to 320x200 if need be. + im = Image.open(path).convert("RGB") + if im.size != (WIDTH, HEIGHT): + im = im.resize((WIDTH, HEIGHT), Image.NEAREST) + return egaGrid(im) + + +def textCells(grid, cellW, cellH): + # Each cell as (blank?, foreground, background): the background is + # the cell's commonest colour. + cells = [] + for cy in range(ROWS): + row = [] + for cx in range(COLS): + colours = Counter(grid[cy * cellH + y][cx * cellW + x] for y in range(cellH) for x in range(cellW)) + common = colours.most_common() + if len(common) == 1: + row.append((True, None, common[0][0])) + else: + row.append((False, common[1][0], common[0][0])) + cells.append(row) + return cells + + +def compareTextMode(oraclePath, joeyPath): + a = textCells(egaGrid(Image.open(oraclePath).convert("RGB")), TEXT_CELL_W, TEXT_CELL_H) + b = textCells(load(joeyPath), CELL, CELL) + cells = [(cx, cy) for cy in range(ROWS) for cx in range(COLS) if a[cy][cx] != b[cy][cx]] + return 0, len(cells), cells + + +def cellBits(g, cx, cy, bg): + return tuple(tuple(1 if g[cy * CELL + y][cx * CELL + x] != bg else 0 for x in range(CELL)) for y in range(CELL)) + + +def cellColours(g, cx, cy): + return Counter(g[cy * CELL + y][cx * CELL + x] for y in range(CELL) for x in range(CELL)) + + +def fontTemplates(path): + g = load(path) + t = {} + for r, s in enumerate(FONT_ROWS): + for i, ch in enumerate(s): + t[cellBits(g, i, r, 0)] = ch + return t + + +def readCell(g, cx, cy, templates): + # (character, foreground, background) or None when it is not text. + colours = cellColours(g, cx, cy) + if len(colours) > 2: + return None + if len(colours) == 1: + return (" ", None, next(iter(colours))) + (a, na), (b, nb) = colours.most_common() + for bg, fg in ((a, b), (b, a)): + ch = templates.get(cellBits(g, cx, cy, bg)) + if ch is not None: + return (ch, fg, bg) + return None + + +def compare(a, b, fontA=None, fontB=None): + real = 0 + text = 0 + cells = [] + for cy in range(ROWS): + for cx in range(COLS): + diff = sum(1 for y in range(cy * CELL, cy * CELL + CELL) for x in range(cx * CELL, cx * CELL + CELL) + if a[y][x] != b[y][x]) + if diff == 0: + continue + if fontA and fontB: + ta = readCell(a, cx, cy, fontA) + tb = readCell(b, cx, cy, fontB) + if ta and tb: + if ta[0] == tb[0] and (ta[0] == " " or ta[1:] == tb[1:]) and ta[2] == tb[2]: + continue + text += 1 + cells.append((cx, cy, ta[0], tb[0])) + continue + real += diff + cells.append((cx, cy)) + return real, text, cells + + +def messageWindows(g): + # Message windows: a red frame run of at least 16 pixels below white. + found = [] + for y in range(1, HEIGHT - 1): + x = 0 + while x < WIDTH: + if g[y][x] == WINDOW_FRAME and g[y - 1][x] == WINDOW_BG: + x2 = x + while x2 < WIDTH and g[y][x2] == WINDOW_FRAME: + x2 += 1 + if x2 - x >= WINDOW_MIN_RUN: + found.append((x, y, x2 - 1)) + x = x2 + else: + x += 1 + return found + + +def diffImage(a, b, cells, path): + marked = set((c[0], c[1]) for c in cells) + out = Image.new("RGB", (WIDTH * 3, HEIGHT)) + for y in range(HEIGHT): + for x in range(WIDTH): + out.putpixel((x, y), EGA[a[y][x]]) + out.putpixel((WIDTH + x, y), EGA[b[y][x]]) + if a[y][x] != b[y][x] and (x // CELL, y // CELL) in marked: + d = (255, 0, 0) + else: + d = tuple(v // 4 for v in EGA[a[y][x]]) + out.putpixel((2 * WIDTH + x, y), d) + out.resize((WIDTH * 6, HEIGHT * 2), Image.NEAREST).save(path) + + +if __name__ == "__main__": + args = sys.argv[1:] + fonts = (None, None) + if "--fonts" in args: + i = args.index("--fonts") + fonts = (fontTemplates(args[i + 1]), fontTemplates(args[i + 2])) + del args[i:i + 3] + if len(args) < 2: + sys.exit("usage: compareFrames.py ORACLE JOEY [DIFF.png] [--fonts ORACLEFONT JOEYFONT]") + if isTextMode(args[0]): + real, text, cells = compareTextMode(args[0], args[1]) + print("text mode: cells=%d %s" % (text, cells[:16])) + else: + a = load(args[0]) + b = load(args[1]) + real, text, cells = compare(a, b, fonts[0], fonts[1]) + print("real=%d text=%d %s" % (real, text, cells[:16])) + if len(args) > 2: + diffImage(a, b, cells, args[2]) + sys.exit(1 if real or text else 0) diff --git a/tests/tools/dosDrive.py b/tests/tools/dosDrive.py new file mode 100755 index 0000000..1011a3e --- /dev/null +++ b/tests/tools/dosDrive.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# dosDrive.py - run a DOS program in DOSBox Staging under a private Xvfb, +# type at it from an input script and take raw 320x200 screenshots +# (Ctrl+F5). scripts/oracle.sh drives Sierra's interpreter with it, using +# the same scripts tests/hostPort.c plays. +# +# dosDrive.py HOSTDIR COMMAND OUTDIR SCRIPT [--set section.key=value ...] +# +# Script lines ('#' starts a comment, except in a type line): +# wait SECONDS let SECONDS pass +# 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 +# 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) ... +# stop ... and stop, leaving OUTDIR/NAME.avi +# A shot is Ctrl+F5, which the game also sees as a key press. Recording +# 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. +# Env: DOSBOX (default JoeyLib's toolchains/opt/dosbox/dosbox), JOEYLIB. +import glob +import os +import ctypes +import random +import shutil +import signal +import subprocess +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +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") +KEY_HOLD = 0.12 +KEY_GAP = 0.08 +CLEAR_GAP = 0.6 +CLEAR_TRIES = 8 +SHOT_POLLS = 40 +VIDEO_KEY = "F12" +VIDEO_KEY_SDL_SCANCODE = 69 +VIDEO_POLLS = 50 +VIDEO_POLL_SECONDS = 0.3 +SHOT_POLL_SECONDS = 0.1 +WINDOW_POLLS = 50 +# prctl option: a signal for the child when its parent dies. +PR_SET_PDEATHSIG = 1 +KEYMAP = {" ": "space", ".": "period", ",": "comma", "'": "apostrophe", "-": "minus", + "?": "question", "!": "exclam", "\n": "Return"} + + +def dieWithParent(): + # Runs in each child before it starts: Xvfb and DOSBox die with the + # driver, so a driver killed by timeout -s KILL leaves neither behind. + ctypes.CDLL(None).prctl(PR_SET_PDEATHSIG, signal.SIGKILL) + + +class Dos: + def __init__(self, hostDir, command, outDir, extraSet=None, recording=False): + self.out = outDir + os.makedirs(outDir, exist_ok=True) + self.work = os.path.join(outDir, ".work") + shutil.rmtree(self.work, ignore_errors=True) + os.makedirs(os.path.join(self.work, "cap")) + os.makedirs(os.path.join(self.work, "xdg")) + self.recording = None + self.disp = ":%d" % random.randint(300, 799) + self.xvfb = subprocess.Popen(["Xvfb", self.disp, "-screen", "0", "1024x768x24", "-nolisten", "tcp"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=dieWithParent) + time.sleep(1) + self.env = dict(os.environ, DISPLAY=self.disp, XDG_CONFIG_HOME=os.path.join(self.work, "xdg"), + SDL_AUDIODRIVER="dummy") + args = [DOSBOX, "--noprimaryconf", "--nolocalconf", "--conf", CONF, + "--set", "capture_dir=" + os.path.join(self.work, "cap"), + "--set", "default_image_capture_formats=raw"] + if recording: + mapper = os.path.join(self.work, "video.map") + with open(mapper, "w") as f: + f.write('hand_video "key %d"\n' % VIDEO_KEY_SDL_SCANCODE) + args += ["--set", "mapperfile=" + mapper] + 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"), + stderr=subprocess.STDOUT, preexec_fn=dieWithParent) + self.win = None + for _ in range(WINDOW_POLLS): + time.sleep(0.2) + self.win = self.findWin() + if self.win: + break + if not self.win: + self.stop() + raise RuntimeError("no DOSBox window") + self.t0 = time.time() + + def findWin(self): + r = subprocess.run(["xdotool", "search", "--name", "."], env=self.env, capture_output=True, text=True) + for w in r.stdout.split(): + p = subprocess.run(["xdotool", "getwindowpid", w], env=self.env, capture_output=True, text=True) + if p.stdout.strip() == str(self.proc.pid): + return w + return None + + def xdo(self, *a): + subprocess.run(["xdotool"] + list(a), env=self.env, capture_output=True) + + def key(self, name, hold=KEY_HOLD): + self.xdo("keydown", "--window", self.win, name) + time.sleep(hold) + self.xdo("keyup", "--window", self.win, name) + time.sleep(KEY_GAP) + + def typeText(self, text): + for ch in text: + if ch.isupper(): + self.xdo("keydown", "--window", self.win, "shift") + self.key(ch.lower()) + self.xdo("keyup", "--window", self.win, "shift") + else: + self.key(KEYMAP.get(ch, ch)) + + def shot(self, name): + before = set(glob.glob(os.path.join(self.work, "cap", "*.png"))) + self.xdo("key", "--window", self.win, "ctrl+F5") + for _ in range(SHOT_POLLS): + time.sleep(SHOT_POLL_SECONDS) + now = set(glob.glob(os.path.join(self.work, "cap", "*.png"))) - before + if now: + time.sleep(0.2) + shutil.move(now.pop(), os.path.join(self.out, name + ".png")) + return True + print("shot %s: no capture" % name, file=sys.stderr) + return False + + def record(self, name): + self.recording = name + self.xdo("key", "--window", self.win, VIDEO_KEY) + + def stopRecording(self): + # The file is finished when it stops growing. + self.xdo("key", "--window", self.win, VIDEO_KEY) + last = -1 + for _ in range(VIDEO_POLLS): + time.sleep(VIDEO_POLL_SECONDS) + videos = glob.glob(os.path.join(self.work, "cap", "*.avi")) + if not videos: + continue + size = os.path.getsize(videos[0]) + if size == last: + shutil.move(videos[0], os.path.join(self.out, self.recording + ".avi")) + self.recording = None + return True + last = size + print("record %s: no video" % self.recording, file=sys.stderr) + return False + + def clearWindows(self): + for _ in range(CLEAR_TRIES): + path = os.path.join(self.out, "_clear.png") + self.shot("_clear") + showing = os.path.exists(path) and compareFrames.messageWindows(compareFrames.load(path)) + if os.path.exists(path): + os.remove(path) + if not showing: + return + self.key("Return") + time.sleep(CLEAR_GAP) + + def stop(self): + try: + self.proc.kill() + except OSError: + pass + self.proc.wait() + self.xvfb.kill() + self.xvfb.wait() + shutil.rmtree(self.work, ignore_errors=True) + + def run(self, script): + for raw in script.splitlines(): + line = raw.strip() if raw.strip().startswith("type ") else raw.split("#", 1)[0].strip() + if not line: + continue + op, _, arg = line.partition(" ") + if op == "wait": + time.sleep(float(arg)) + 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 == "shot": + self.shot(arg) + elif op == "clear": + self.clearWindows() + elif op == "record": + self.record(arg) + elif op == "stop": + self.stopRecording() + elif op == "down": + self.xdo("keydown", "--window", self.win, arg) + elif op == "up": + self.xdo("keyup", "--window", self.win, arg) + else: + raise ValueError("bad script op " + op) + + +if __name__ == "__main__": + if len(sys.argv) < 5: + sys.exit("usage: dosDrive.py HOSTDIR COMMAND OUTDIR SCRIPT [--set section.key=value ...]") + hostDir, command, outDir, scriptFile = sys.argv[1:5] + sets = [] + rest = sys.argv[5:] + while rest: + if rest[0] == "--set" and len(rest) > 1: + sets.append(rest[1]) + rest = rest[2:] + else: + rest = rest[1:] + script = open(scriptFile).read() + recording = any(line.split()[:1] == ["record"] for line in script.splitlines()) + dos = Dos(hostDir, command, outDir, sets, recording) + try: + dos.run(script) + finally: + dos.stop() diff --git a/tests/tools/shakeMeasure.py b/tests/tools/shakeMeasure.py new file mode 100644 index 0000000..e2eda9e --- /dev/null +++ b/tests/tools/shakeMeasure.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# shakeMeasure.py - read shake.screen's effect out of a recording: Sierra's +# (a DOSBox AVI, every 70 Hz frame) or JoeyAGI's (agiHost's NAME.frames +# list with a NAME-HASH.ppm per different frame). +# +# Each frame is compared with the last frame that was in place: the same +# picture moved by (dx, dy) is a shaken frame, anything else a new +# in-place frame. Consecutive shaken frames make a beat; beats less than +# GAP_MAX frames apart make one shake. +# +# shakeMeasure.py RECORDING [OTHER] +# prints one line per shake (beats, frames moved / in place per beat, the +# offset); with OTHER it compares the two and exits 1 if they differ. +import os +import subprocess +import sys + +from PIL import Image + +WIDTH = 320 +HEIGHT = 200 +SEARCH_X = 16 +SEARCH_Y = 8 +SAMPLE = 3 +GAP_MAX = 20 + + +def framesFromAvi(path): + proc = subprocess.Popen(["ffmpeg", "-loglevel", "error", "-i", path, "-f", "rawvideo", + "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE) + size = WIDTH * HEIGHT * 3 + while True: + buf = proc.stdout.read(size) + if len(buf) < size: + break + yield buf + proc.wait() + + +def framesFromHost(path): + base = path[:-len(".frames")] + cache = {} + with open(path) as f: + for line in f: + _, h = line.split() + if h not in cache: + cache[h] = Image.open("%s-%s.ppm" % (base, h)).convert("RGB").tobytes() + yield cache[h] + + +def pixel(buf, x, y): + i = (y * WIDTH + x) * 3 + return buf[i:i + 3] + + +def offset(frame, ref): + # The (dx, dy) that maps ref onto frame exactly, or None. + for dy in range(-SEARCH_Y, SEARCH_Y + 1): + for dx in range(-SEARCH_X, SEARCH_X + 1): + ok = True + for y in range(max(0, dy), min(HEIGHT, HEIGHT + dy), SAMPLE): + for x in range(max(0, dx), min(WIDTH, WIDTH + dx), SAMPLE): + if pixel(frame, x, y) != pixel(ref, x - dx, y - dy): + ok = False + break + if not ok: + break + if ok: + return (dx, dy) + return None + + +def measure(frames): + # A list of (moved?, offset) per frame. + ref = None + known = {} + marks = [] + for frame in frames: + if ref is None or frame == ref: + ref = frame + marks.append(None) + continue + key = hash(frame) + if key not in known: + known[key] = offset(frame, ref) + o = known[key] + if o is None or o == (0, 0): + ref = frame + known.clear() + marks.append(None) + else: + marks.append(o) + return marks + + +def shakes(marks): + beats = [] + i = 0 + while i < len(marks): + if marks[i] is None: + i += 1 + continue + start = i + while i < len(marks) and marks[i] is not None: + i += 1 + beats.append((start, i - start, marks[start])) + groups = [] + for b in beats: + if groups and b[0] - (groups[-1][-1][0] + groups[-1][-1][1]) <= GAP_MAX: + groups[-1].append(b) + else: + groups.append([b]) + lines = [] + for g in groups: + moved = sorted(set(b[1] for b in g)) + still = sorted(set(g[k + 1][0] - g[k][0] - g[k][1] for k in range(len(g) - 1))) + offsets = sorted(set(b[2] for b in g)) + lines.append("%d beats, moved %s frames, in place %s frames, offset %s" % (len(g), moved, still, offsets)) + return lines + + +def load(path): + if path.endswith(".frames"): + return framesFromHost(path) + return framesFromAvi(path) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit("usage: shakeMeasure.py RECORDING [OTHER]") + first = shakes(measure(load(sys.argv[1]))) + print("%s:" % os.path.basename(sys.argv[1])) + for line in first: + print(" " + line) + if len(sys.argv) > 2: + second = shakes(measure(load(sys.argv[2]))) + print("%s:" % os.path.basename(sys.argv[2])) + for line in second: + print(" " + line) + sys.exit(0 if first == second else 1)