Logo Pending


Sluiceboxes and SetPorts

Today I got a delightful (and long) email from sluicebox, of the ScummVM SCI team. He wrote about a lot of things but one thing stood out and he’s right, I should write about it.

Remember when I fixed the imitation AGI windows in Space Quest 4? There’s something very strange going there that sluicebox pointed out in the email.

If you’ll remember:

(method (open &tmp port temp1)
  ; temp0 was unused so we're taking it for proper SetPorting.
  (= color gColor)
  (= back gBack)
  ; Set our type to ONLY wCustom, not wCustom|wNoSave, and open.
  (= type 128)
  (super open:)
  ; Nothing will have appeared because wCustom don't draw anything, but a port has been set up!
  ; Switch to drawing on the whole screen but also *save the window's port*.
  (= port (SetPort 0))
 
  (= temp1 1)
  ; ...
  (Graph grUPDATE_BOX lsTop lsLeft lsBottom lsRight 1)
 
  ; Reset to the window's port.
  (SetPort port)
)

But if you look at this GitHub commit from ScummVM you’ll see the interesting description

SSCI doesn’t return zero; it doesn’t return anything. This shouldn’t affect any games since no scripts should depend on a non-existent return value, but this discrepancy came up while investigating a fan script that accidentally relies on this.

So I checked the leaked source code that I made SCI11+ from.

global KERNEL(SetPort)
{
	if (argCount >= 6)
	{
		picWind->port.portRect.top = arg(1);
		picWind->port.portRect.left = arg(2);
		picWind->port.portRect.bottom = arg(3);
		picWind->port.portRect.right = arg(4);
		picWind->port.origin.v = arg(5);
		picWind->port.origin.h = arg(6);
		if (argCount >= 7)
			InitPicture();
	}
	else
	{
		if (arg(1))
		{
			if ((arg(1)) == -1)
				RSetPort(menuPort);
			else
				RSetPort((RGrafPort*)Native(arg(1)));
		}
		else
		{
			RSetPort((RGrafPort*)RGetWmgrPort());
		}
	}
}

No return value, which is obvious really because the KERNEL define expands to a void function. Return values are instead handled by setting the acc global variable. So lets dig a little deeper.

RSetPort proc	pPtr:word
	mov	ax, pPtr
	mov	rThePort, ax
	ret
RSetPort endp

Nothing. It sets the rThePort global and that’s all. There’s an RGetPort function right above that does the opposite, but nothing in the kernel function calls that.

Looking back at my description of BorderWindows, there’s an important difference:

(= oldPort (GetPort))
(SetPort 0)
(Graph grUPDATE_BOX lsTop lsLeft lsBottom lsRight VISUAL)
(SetPort oldPort)

It’s very interesting indeed how this happened to Just Work. Even so, I should probably go back and correct that SQ4 script.

[ , , ] Leave a Comment

Space Quest 4 – Roger Wilco and the Failed Attempt at Being Cute

While playing Space Quest 4 – Roger Wilco and The Time Rippers, you travel back and forth between several different time periods, each designated by a sequel number. The introduction and ending are set in SQ4, the main plot in SQ12 – Vohaul’s Revenge II, and from there you travel to SQ1 – The Sarien Encounter, SQ10 – Latex Babes of Estros, and in an easter egg, SQ3 – The Pirates of Pestulon.

While in the SQ1 era, you revisit Ulence Flats. Literally, you arrive just after the original Roger is done there and leaves. In the SCI remake of SQ1, you actually see the time pod from SQ4 arrive right behind you when you leave the area, but SQ4 came out before the SQ1 remake so you revisit the AGI version instead. Sort of. The resolution’s all wrong, but who cares?

One thing you’ll quickly notice is that instead of the usual BorderWindow frames, this part of the game uses a custom frame meant to evoke AGI’s. It is, however, immediately clear (at least to me) that they fucked it up.

That’s one unsightly black border! Also, the window is light gray instead of white but that’s not the issue here.

If you look closely and remember what I wrote about window style bits before, you might recognize that it’s drawing a white light gray window with a red border, and then a nwTRANSPARENT window on top. What does the actual code say? Here’s Sq1Window, which is used in lieu of BorderWindow in all Ulence Flats rooms:

(class Sq1Window of SysWindow
  (properties
    underBits 0
    pUnderBits 0
    bordWid 3
  )
 
  (method (dispose)
    (SetPort 0)
    (Graph grRESTORE_BOX underBits)
    (Graph grRESTORE_BOX pUnderBits)
    (Graph grREDRAW_BOX lsTop lsLeft lsBottom lsRight)
    (super dispose:)
  )
 
  (method (open &tmp temp0 temp1)
    (SetPort 0)
    (= color gColor)
    (= back gBack)
    (= temp1 1)
    (if (!= priority -1) (= temp1 (| temp1 $0002)))
    (= lsTop (- top bordWid))
    (= lsLeft (- left bordWid))
    (= lsRight (+ right bordWid))
    (= lsBottom (+ bottom bordWid))
    (= underBits (Graph grSAVE_BOX lsTop lsLeft lsBottom lsRight 1))
    (if (!= priority -1)
      (= pUnderBits (Graph grSAVE_BOX lsTop lsLeft lsBottom lsRight 2))
    )
    ; Draw the background
    (Graph grFILL_BOX lsTop lsLeft lsBottom lsRight temp1 back priority)
    ; Draw the border
    (Graph grDRAW_LINE (+ lsTop 1) (+ lsLeft 1) (+ lsTop 1) (- lsRight 2) global131 priority)
    (Graph grDRAW_LINE (- lsBottom 2) (+ lsLeft 1) (- lsBottom 2) (- lsRight 2) global131 priority)
    (Graph grDRAW_LINE (+ lsTop 1) (+ lsLeft 1) (- lsBottom 2) (+ lsLeft 1) global131 priority)
    (Graph grDRAW_LINE (+ lsTop 1) (- lsRight 2) (- lsBottom 2) (- lsRight 2) global131 priority)
    (Graph grUPDATE_BOX lsTop lsLeft lsBottom lsRight 1)
    ; Open a logical window for the contents to be drawn into
    (= type 129)
    (super open:)
  )
)

And here’s the trick: not only does it open a logical window after drawing it, but it opens one with the wrong style.

Fix idea #1. Open the logical window before drawing it.

(method (open &tmp temp0 temp1)
  (= type 129)
  (super open:)
  (SetPort 0)
  (= color gColor)
  ;...
)

That ain’t it. Not only does it put the contents of the window in the corner of the screen (because we’re using SetPort wrong) but when the window closes, the frame appears behind it:

No, no. The way I solved it was like this:

(method (open &tmp port temp1)
  ; temp0 was unused so we're taking it for proper SetPorting.
  (= color gColor)
  (= back gBack)
  ; Set our type to ONLY wCustom, not wCustom|wNoSave, and open.
  (= type 128)
  (super open:)
  ; Nothing will have appeared because wCustom don't draw anything, but a port has been set up!
  ; Switch to drawing on the whole screen but also *save the window's port*.
  (= port (GetPort)) ; 2022-07-29 update, hi sluicebox
  (SetPort 0)
 
  (= temp1 1)
  ; ...
  (Graph grUPDATE_BOX lsTop lsLeft lsBottom lsRight 1)
 
  ; Reset to the window's port.
  (SetPort port)
)

And that fixes everything!

No extra black border, no misplaced contents, and no leftovers! If you have the CD-ROM edition, you may be able to drop this file in the game’s directory and rename it to 706.scr.

Now, eagle-eyed viewers might notice that in the very broken screenshot, the text was drawn on a white background, while the window itself is light gray. This is because each graphical port in the system tracks its own color settings, among other things. A logical window is a kind of port, as is the screen as a whole. Since the broken version called (SetPort 0) after (super open:), its contents were drawn on the screen port, not the window’s. And the screen port, by default, has a white background color.

Shoutouts to the Space Quest Historian for putting me on this track with his playthrough video. And as such, see ya on the Chronostream, time jockeh!

[ , , ] 2 Comments on Space Quest 4 – Roger Wilco and the Failed Attempt at Being Cute

Ball Road

The dictionary for AGI and SCI games’ text parser input is stored in alphabetical order. This allows a prefix-based compression:

  • another
  • any
  • appear
  • appearance
  • apple
  • at
  • attack

Though the formats for the two engines’ dictionaries are completely different, they share this one aspect. Each of these words is then assigned a group number which is then used to store the said specs. I’ve written about that before. The thing is that when you decompile a game script, you can’t tell which synonym from a given group was originally used. And that’s why when you decompile Leisure Suit Larry 2 and look in the scripts regarding really any female character you’ll see them being called bimbos.

(if (or (Said 'call/bimbo,agent') (Said 'get,buy/ticket'))

That is of course because “bimbo” is in the same group (#42) as “woman” and “lady”, but alphabetically comes before them. “Agent” is in its own group (#50) together with various other jobs. You can call this particular woman either by gender or by profession. You can even call her a KGB agent and the game will allow it. By that same token, “call” is in the same group as the “talk” you’d expect to see here (#11).

But the decompiler has little to no idea of these things.

I have recently acquired the full source code for Larry 2, and that shows a slightly different, more sensible word choice:

(if (or (Said 'talk/girl, clerk') (Said 'get, buy/ticket'))

That is of course because these are the actual word groups being used here:

11 42 50
talk
speak
converse
call
woman
girl
lady
stewardess
blond
chick
blonde
slut
broad
bimbo
maid
receptionist
secretary
mother
mama
momma
mom
clerk
waiter
waitress
bartender
storekeeper
shopkeeper
agent
kgb
kgbishna
custom
attendant
shark

You can see how alphabetical order would mess that up.

And by that same token I can now securely say that the debug cheat code in Larry 3 is not in fact “ascot backdrop”.

“Backdrop” in Larry 3 is in group #1063 together with “put”, “drop”, “release”, “set”, “stash”, and various other “put something here” verbs. You know by now how the smart fella who discovered the debug cheat may have gone about it, and how “backdrop” would be the first word in that group. The canonical phrase however, is

Ascot Place

Because of course if I have the Larry 2 code why wouldn’t I have Larry 3 as well?

(cond
  ((Said 'ascot/place')
    (^= debugging TRUE)
    (if debugging
      (Print "Hi, Al!")
    else
      (Print "\"Goodbye.\"")
    )
  )

The question is… why is this the debug phrase?

And the answer? It’s a callback to Larry 2:

And just like that this post’s title makes a little sense.

[ , , , ] Leave a Comment

More regarding Interrupt 21

Last time I explained how your standard file rename function as seen in MS-DOS worked. You’d set up two CPU registers with pointers to the old and new names, set AH to 0x56, and called Int 0x21. Easy, right? And then I went into detail on how malformed inputs were handled. They weren’t handled too well, and DOSBox does it differently from MS-DOS on top of that.

But what if we had a file system and rename function that did support spaces? Maybe more than eight characters, even? In mixed case?

That is of course VFAT, an extension to regular FAT16 available in Windows 95, NT 3.5, and later. With a VFAT driver, most of the old file operations available from Int 0x21 had counterparts installed that generally took the same arguments and had the same numbers, but accepted long filenames.

So to rename a file with long filename support, you’d do exactly what you’d do before but instead of setting AH to 0x56 you’d set AX to 0x7156. Assuming Windows is running and we use the same inputs as last time, your file will now be named hello world.txt. And that’s all that takes, even if it’s a pure DOS program doing it.

Which raises a question. How do you make a pure DOS program that handles files that may have long names, may be run from Windows, and should not drop any of those long names if it is in fact running in Windows? Well, it turns out all those LFN functions — the ones starting with 0x71, all reset AX to 0x7100 if they’re not installed. A trick of the system, I suppose. So what you could do for your LFN-enabled rename function is try to use 0x7156, see if AX has reset to 0x7100, and if it has, you try again with AH set to 0x56. In other words, it’s time to bring back the rename function from SCI11… or rather a branch of SCI11+ that I’ve been working on.

rename	proc	oldName:ptr byte, newName:ptr byte
	mov	dx, oldName	; ds:dx = old name
	push	ds
	pop	es
	mov	di, newName	; es:di = new name
 
	mov	ax, 7156h	; LFN Rename
	int	21h
	.if	ax == 7100h	; LFN failed, try DOS 2.0 version
		mov	ah, 56h
		int	21h
	.endif
 
	.if	carry?
		xor	ax, ax
	.endif
	ret
rename	endp

It’s that easy. Of course, this is old-school MASM code which has some nice things like .if but that’s just sugar to avoid having to write compares and branches — the concept should be clear enough. An attempt to rename a file to Introduction.txt will result in exactly that on Windows, or transparently collapse to introduc.txt on plain DOS.

Note that in the actual SCI11+ code, if you’re crazy enough to look it up, there’s an extra function I made that’s called right before the DOS 2.0 rename call that replaces all spaces with underscores, which renders them about 100% not as confusing and untouchable as the one shown last time. I left that part out for brevity.

[ , , ] Leave a Comment

This is why you sanitize your inputs, 1983 edition

(This is heavily expanded from a few Twitter posts of mine.)

When you write an application that has to rename a file, you have your chosen language and platform’s standard library to do the heavy lifting for you. For example in C it’s usually int rename(const char* oldName, const char* newName), and a bunch of other languages follow suit. Why not, it’s a good function! But what does rename actually do?

In MS-DOS, this’d be handled by Interrupt 0x21, subfunction AH 0x56. By which I mean it’d set two specific processor registers (as mentioned in Save Early, Save How) to point to the old and new file names, set the AH register to 0x56, and execute the INT 0x21 instruction. A function installed by MS-DOS will then take over, doing the actual renaming, possibly returning an error value which the C function can immediately use as its’ return value. Since SCI has its own “need-to-use” library…

rename	proc	oldName:ptr byte, newName:ptr byte
	mov	dx, oldName	; ds:dx = old name
	push	ds
	pop	es
	mov	di, newName	; es:di = new name
 
	mov	ah, 56h
	int	21h
 
	.if	carry?
		xor	ax, ax
	.endif
	ret
rename	endp

(Full disclosure: the SCI code actually includes a dos macro to save the programmers some typing. I unrolled it here for illustration purposes.)

All of this pretty much matches what you can find on Ralph Brown’s list. Given a suitable function prototype in C such as the one in the second paragraph, SCI can now call its own rename function as it desires.

Enough about SCI though, its function as a practical example is at an end.

But what if you gave it bad inputs? Sure, if the old name doesn’t refer to an existing file it will return 2 “file not found”, but what if the new name isn’t quite valid? Remember, this is MS-DOS; we don’t have the luxury of long file names here. It’s 8.3 or bust. I don’t see any sanity checks in the above function, and Brown’s documentation only speaks of splats.

So what happens if we have a file boop.txt and call rename("boop.txt", "hello world.txt")?

In DOSBox, you’d end up with a file hellowor.txt. You are free to further manipulate this file in any way you please. The command line won’t choke on it, file managers won’t get confused. If you wanted to manually rename it back to boop.txt from the command line, ren hellowor.txt boop.txt will work perfectly fine.

This is actually not true in real MS-DOS. If your program were to run on a real MS-DOS installation, you’d end up with hello wo.txt, an 8.3 file with a space in it. And no contemporary file manager I’ve seen can handle that. The ren command built into command.com can’t parse it — ren hello wo.txt boop.txt is three arguments where ren expects only two, and the first isn’t an existing file’s name that it can change to wo.txt.

In cmd.exe of course you can use double quotes to make it unambiguously two arguments, but this isn’t cmd.exe. What about some file managers though? I have two, Norton Commander and its big brother Norton Desktop.

In Norton Commander, the file list shows hello wo.txt, and its rename function can handle it. So can the built-in editor and viewer. Top marks for Norton Commander!

Norton Desktop on the other hand is not so sturdy. It can show the file in the list but that’s all. Trying to rename it back to boop.txt reveals the incorrectness of the source file’s name quite succinctly:

Technically, this is true. You’re not supposed to have spaces in the middle of a FAT 8.3 file name. If a file has less than eight characters before the dot, it’s secretly padded with spaces, and so are the three extension characters. And the dot isn’t even — the true name as written in the FAT directory would be BOOP    TXT. But that’s just one way Norton Desktop trips. Its viewer seems to be passed the nonexistent hello. It shrugs and asks which existing file we want to open. Its editor is given the same argument(s?) and lets us edit a brand new file named hello. In Norton Desktop’s world, it can see the file, but it can’t do much with it.

What about a contemporary Windows? Can, let’s say, the Notepad from Windows 3.1 handle this file? Okay, so technically this is commdlg.dll talking, but we’re playing for effect here.

Of course not, what did you expect by now!? Norton Commander only worked because it didn’t care enough! Would you really think one of the companies who made the FAT file system would blithely ignore one of the cardinal rules at the time?

Pshaw!

Next time, we gettin’ hacky.

 

…Wait, hold up. Why does it say 1983 in the title? Well, if you notice on Ralph Brown’s site the rename function was introduced in DOS 2, which was first released in 1983. And so was I.

[ , , ] Leave a Comment

Police Quest’s flashing siren lights

The flashing siren lights in the title screens for Police Quest 1 and 3 are sort of interesting, because they are not quite a simple matter of calling (Palette palANIMATE) once or twice. In fact it’s called eight times each frame! Here’s the final result:

And here’s the Script at the heart of it:

(instance cycleColors of Script
  (method (changeState newState)
    ; Fun fact: the switch isn't actually needed.
    ; Not in this use-case.
    (switch (= state newState)
      (0
        (Palette palANIMATE 208 213  1) ;blue in the middle
        (Palette palANIMATE 213 218  1)
        (Palette palANIMATE 218 223  1)
        (Palette palANIMATE 223 228  1) ;blue on the side
        ; Note that we're switching from 1 to -1 now.
        (Palette palANIMATE 229 234 -1) ;red in the middle
        (Palette palANIMATE 234 239 -1)
        (Palette palANIMATE 239 244 -1)
        (Palette palANIMATE 244 249 -1) ;red on the side
 
        ; Almost immediately do it all over again
        (= cycles 10000)
        (= state -1)
      )
    )
  )
)

The palette here has a very particular setup. The lowest colors, #208 to #249, are set up like this:

Each of the eight siren colors in the image has its own four-step palette, individually rotated! It looks kinda like this:

If that one black entry wasn’t in the way between blue and red, it’d line up better, but what can you do?

What’s particularly funny about this is of course that no SCI interpreter with fewer than 256 colors implements this feature.

The cycleColors script is still there and is still invoked. Just like with the chronostream animation in Space Quest 4.

[ , , , ] Leave a Comment

Save early, delete when you need

There’s one interesting tidbit missing here, which is how deletion (SCI1 and later) is implemented. Namely by manipulating the .DIR file in the script, and not – as any sane person would do – with a kernel call.

So wrote Iskovlun in a comment some time back. Let’s see exactly how insane it really is.

; First we open up the directory file.
; Confusing, I know, to call it a directory file. Perhaps
; "catalog" would be better considering a directory is
; already something else. And in SCI32, they did!
((= fd (File new:))
  name: (DeviceInfo diMAKESAVEDIRNAME @str (gGame name?))
  open: fCREATE
)
 
; The format of a save game directory is pretty straight-
; forward -- a word for the index, then the name, terminated
; with an $0A, repeat until done, end with $FFFF.
 
; (File write:) requires a pointer to the data it is to write,
; so we need to put values into variables, rather than just
; passing them immediately. Well, unless you have SCI11+ with
; the extra file kernel calls I nabbed from SCI32 and a matching
; File class, in which case you could just do (File writeByte:
; $0A) if you were so inclined!
(= ret $0A0A)
 
; Now we write the number and name of each saved game, EXCEPT
; for the one that was selected for deletion.
(for ((= i 0)) (< i numGames) ((++ i))
  (if (!= i selected)
    (fd write: @[nums i] 2)
    (fd writeString: @[names (* i COMMENTBUFF)])
    (fd write: @ret 1)
  )
)
 
; Now we write the terminating $FFFF to finish the catalog
; I mean directory off.
(= ret -1)
(fd
  write: @ret 2
  close:
  dispose:
)
 
; Now that that's done, we can safely delete the actual
; save game file.
(DeviceInfo diMAKESAVEFILENAME (gGame name?) [nums selected])
(FileIO fiUNLINK @str)

I almost feel like doing the so-called sane thing and adding a DeleteGame kernel call to SCI11+.

[ , ] Leave a Comment

VGA Versus VESA

We’re all familiar here with the classic 320×200 pixels, 256 color screen mode popularized by the VGA video card, colloquially known as Mode 13h. Most old DOS games from before a particular point in time used it. But what if you want or need bigger? Or more colors? Enter the Super VGA cards with their extended VESA modes.

These VESA modes number 100h and higher, but which exactly are available and what their specs are depends on your exact hardware. As such you can’t rightly assume a certain mode is available and will be that particular resolution and color depth. What you’re supposed to do is ask the system what VESA video modes are available, walk the list to see if you find the one you need, and note its number.

All I have is a copy of DOSBox and a copy of VirtualBox, and of vesachk.exe, available here if you want to try it out yourself. This application gives you that list. Now, the two systems yield vastly different results, mirroring differences in video hardware. DOSBox for example emulates some form of S3 card.

I’ve noticed that when an SCI32 game is made for low-res it runs in plain ol’ Mode 13h, but if it’s meant for higher it’ll use Mode 101h. That’s one of the few in the list that DOSBox and VBox agree on — it has 640×480 resolution at 8 bits per pixel, packed, starting at 0xA0000000.

On the one hand, a regular old 16-bit DOS application wouldn’t be able to address all 307,200 pixels at once the way you can in mode 13h. On the other, a 32-bit application would have direct access to the full linear frame buffer no matter its size. A 16-bit application would need trickery to reach any pixel beyond a certain point, setting the window registers to basically shift the next part of video memory into that same 64 kb block.

This is why SCI32, when switching to mode 13h, just does so:

void Vga::Set320x200()
{
	union REGS reg;
 
	reg.w.ax = 0x0013;
	int386(0x10, &reg, &reg);
	SetVideoMode(1);	// clear all video memory
	SetVideoMode(0);	// back to Normal Mode 13
	lenx = 320;
	leny = 200;
}

But when it switches to mode 101h, it jumps through several hoops. First it checks if VESA is supported at all, then it switches to mode 101h, and then it does some more checks to see if things are as they should be, bailing out if they’re not.

And that’s all good.

But what if you were to find a VESA video mode that was 320×200 with 256 colors? Is there such a thing? A redundancy with mode 13h? As a matter of fact, there is! On the S3 emulated by DOSBox, it’s VESA mode 150h, and once you switch to it things work exactly the same as in mode 13h, except the memory access timing or whatever is different.

; VGA mode 13
mov ah, 0x00
mov al, 0x13
int 0x10
 
; VESA mode 150h
mov ax, 4F02h
mov bx, 150h
int 10h

But on whatever VBox has to offer, which is a vastly longer list with about a hundred more modes, this could be 146h, 346h, 546h, or 746h. And that’s why you really should ask the system about it!

But SCI32 basically assumes 101h is what it needs to be and presumably the folks at Sierra tested a bunch of contemporary cards and found this to be true.

Fun fact: SCI16 has all its video driver code in files like VGA320.DRV, but SCI32’s VGA.DRV is practically empty. It’s technically a valid SCI driver file but it’s basically just a header. Same with its VESA.DRV. All their code is in the interpreter itself, much like the mouse driver. It’s only there so the installer can offer it and the interpreter can determine which was chosen. And even then, the interpreter for later high-res only games like Gabriel Knight 2 will happily ignore all that.

[ , , ] Leave a Comment

Linguistics, my weakness!

This topic was suggested by Phil Fortier.

I don’t normally bother with SCI0 except to gush over the artwork, but what can you do? Phil suggested I explain the parser, so I’ll try to explain the goddamn parser.

Let’s look at something more basic first. In AGI, there was little to no sense of grammar so your input had to be more rigid. This does make it simpler to explain and thus get something to work up from.

We start by defining a dictionary of words, mapping groups of synonyms to number values:

0 (a whole bunch of “filler” words)
1 anyword
2 check, examine, look, see
3 swim, swimming, wade, wading
4 enter, go
5 acquire, get, pick, pluck, rob, swipe, take
6 climb, scale
21 building, castle, cottage, fort, house, leanto, palace, tower
22 door, doors
23 dragon

The “anyword” entry is literally that. We’ll get back to that.

In the script code, system scripts and the current room’s script alike can at any point check to see if you said something:

if (said("check", "room"))
{
  print("You are standing outside a castle surrounded by an alligator filled moat.");
}
if (said("pet", "alligator"))
{
  print("What!  Are you crazy?");
}

It should be noted that the way it’s presented here is but an illusion, granted us by the decompiler. The said function’s parameters are actually a straight series of numbers, referencing the dictionary. This way it can match both >CHECK ROOM and >LOOK ROOM. But what if you typed >LOOK AT ROOM? There’s no check for that.

When the AGI engine parses your input, it goes through it word for word and tries to match everything to the dictionary. It first sees look, matching that to entry 2 and remembering it as such. It then sees at which is one of the “filler” words and skips it. The last word found is room, which is matched to 137. So now the “said buffer” as it were contains 2 137 and the said function can compare its parameters against it. Two special meta-words, anyword and rol, are available to match literally anything and to ignore the rest of the buffer if any.

If you were to tell King’s Quest 1 to >LOOK AT THE CROCODILES it’d tell you it doesn’t understand “crocodiles”. Those aren’t in the dictionary so there’s nothing to match them against. Those are alligators in the moat.

SCI is a bit more involved than that, but you should have a good basis to work from now.

In SCI0, the dictionary is extended to include grammatical types for each entry. For example, the first item might be marked “article”. Indeed, it’s a group of words like “the”, “a”, “an’, and for our Spanish players “el”, “la”, and “los”. Then you might have a numbered entry “give, offer” marked as being a verb. Words like “lock” would be marked as being both verbs and nouns.

All that so a very nifty state machine can determine what you want to do, what you want to do it to, and what you want to do it with, among other phrases.

Every time you enter a command, a said event is fired. Through the usual systems, this event is passed down to the current room’s handleEvent method, which can tell it’s a said event. So now we know that something was said in the first place, but what exactly? At this point things turn a little AGI-like again.

(switch (pEvent type?)
  (evSAID
    (cond 
    ((Said 'close/door')
      (Print "Check again! It IS closed.")
    )
    ((Said 'look>')
      (cond 
        ((Said '<in,through/craft,pod,pane[<escape]')
          (Print "This task is impossible since the door is sealed from the inside.")
        )
        ((Said '/pane')
          (Print "The window is clear enough to reveal the blackness inside.")
        )
        ((Said '/door,door')
          (Print "The solidly built door looks to be locked in place.")
        )
        ((Said '/nozzle')
          (Print "The pod's thrusters are very small. They have been cold for a long time.")
        )
        ((Said '/craft,pod[<escape]')
          (Print "This is the escape pod which safely whisked you away from Vohaul's burning asteroid fortress.")
        )
        ((Said '[<at,around,in][/area,!*]')
          (Print "You are standing in a debris-cluttered junk bay.")
        )
      )
    )
    ; ...
  )
)

First of all, don’t be fooled. Those strings have single quotes around them for a reason. They too are stored as numbers, and so are the other characters. For example, 'close/door' is 1157, 242, 2110. The actual order of things might be a little off to see though. Obviously the > at the end of the look clause means that the rest of the sentence is to be considered separately. That’s all good. But the / does not mean to continue from here, since 'close/door' has it right there in the middle. No, the / means that the next word is to be the direct object. The thing you want to close. The first word is the verb, considering the imperative nature of these games. If there’s a second /, that’ll be the indirect object, but we don’t have one here. The < in '/craft,pod[<escape]' modifies the object it succeeds, while the [] around it make it optional. Thus, 'look/craft,pod[<escape]' matches >LOOK CRAFT>LOOK AT ESCAPE SHUTTLE, and various other ways to phrase it. Oddly, even though the description calls it an escape pod, “pod” is not a valid synonym. Oh well.

Update some two years later, turns out Space Quest 3 version 1.018 has a massive script bug involving them adding another word group, but neglecting to recompile all the scripts. As threepwang put it, “30 scripts still reference the old vocab group ids from 0x953 to 0x990 in their Said strings.” Oof. So I decompiled the Amiga version and instead of “chute” it said “leech”. Because alphabetical order. So in the end “escape pod” would be valid, but only on any version other than the very last PC release that’s also in the SQ collections. Great.

But how does the engine know what the verb and objects are? Well, it figures that out via that state machine I mentioned. When you start to enter a command in SCI0, the User script handles showing you the command window and such, then passes what you entered to the Parse kernel command, hereafter “the parser”.

One particular resource lists all the possible types of phrase, such as “shoot”, “talk to dwarf”, or “hit redneck with plank”, but stored in the sense that a verb phrase can be just a verb, a verb followed by a direct object, or a verb followed by a direct object and an indirect object. The parser then tries to find the verb phrase that best fits the input, filling in placeholders until done.

For example, say we entered >LOOK AT HOBO. The parser will try to find the best-fitting verb phrase, starting with a bare core verb. In turn, it will consider the various core verb structures listed in the grammar, eventually finding a bare “just a verb”, which “look” matches, but there might still be a better match. The very next option is indeed “a verb followed by a position”, which matches “look” and “at” in that order. No other core verb structures match, so we can write that down and continue with the rest of our verb phrase. Which is now done.

But again, there might be a verb phrase that better matches our input and we do have more words to consider — we’ve only looked at “look at”, not the whole “look at hobo”!

The next verb phrase is “a core verb followed by a noun phrase.” Hmm. Again, we look through the various grammatical definitions of a “noun phrase”. The first one is “an article followed by a core noun.” Well, we didn’t say to look at the hobo, so that one’s out. The next one wants a hobo with an article and an adjective, then a hobo with only an adjective… but we do eventually get a noun phrase that’s just a core noun, and in turn that core noun is reduced to just the single word “hobo”.

So now we know that our verb is “look” and our direct object is “hobo”. The parser can now generate something from this information that Said can compare against, keeping it in mind until the event is eventually claimed, be it by a successful Said match or the game giving up on your strange input.

[ , , ] Leave a Comment

Skip a bit, brother

Ah yes. The skip button. You don’t see those often in most of the old Sierra adventure games, and to be honest I’m not interested enough in the later ones to check. Sue me. But how do they work?
As usual, let’s look at the scripts.

First, we have Leisure Suit Larry 5 – Passionate Patti does Pittsburgh, which shares its skip system with Freddy Pharkas Frontier Pharmacist. The only notable difference between the two is that, being an SCI11 game, the latter uses Messages instead of Text resources. This system has two dedicated parts to it, plus how the current scene reacts:

(instance icon5 of IconI
  ; ...
  (method (select)
    (return
      (if (and gFFRoom (super select: &rest))
        ; That is, if we had a gFFRoom set and the usual response to a button click was true.
        (gIconBar hide:)
        (if (Print "Do you really want to skip ahead?" #title "Fast Forward" #button "Yes" 1 #button "Oops" 0)
          (if (== gFFRoom 1000)
            ; In this case, we want to cue something.
            (if (IsObject gFFScript)
              (gFFScript cue:)
              (SetFFRoom 0)
            else
              (Print "ERROR: Object passed to SetFFRoom isn't an object you silly person!")
            )
          else
            ; In the *other* case, we just want to go somewhere.
            ; This option is good for larger cutscenes.
            (gRoom newRoom: gFFRoom)
            (= gFFRoom (+ gFFRoom 1000))
            ; ... I'm... not entirely sure what that was for.
          )
        )
      else
        (return 0)
      )
    )
  )
)
 
(procedure (SetFFRoom room script)
  (if (not room)
    (= gFFRoom 0)
    (= gFFScript null)
    (gIconBar disable: 5)
  else
    (= gFFRoom room)
    (if (and (> argc 1) (== room 1000))
      (= gFFScript script)
    )
    (gIconBar enable: 5)
  )
)

Call SetFFRoom with anything but 1000, and you set up a skip to another room. Call it with “room” 1000 and a cue-able object otherwise. Pretty simple, I don’t think I need to bother with a practical example.

Incidentally, this makes one of the examples of a non-standard procedure whose name is absolutely certain.

On to Leisure Suit Larry 6 – Shape Up or Slip Out. This is the low-res SCI11 version, but I sincerely doubt the SCI2 version is much different. It has only one shared part, the icon, without a setup procedure. Note that icon5 is exported as ScriptID 0 8, hence the references throughout.

(instance icon5 of BarIconI
  ; ...
  (method (doit &tmp theTarget)
    (cond 
      ((not gSkipTarget)
        ; Don't do anything if no skip was set up.
        0
      )
      ((not (IsObject gSkipTarget))
        ; Skip target is a number, so a room.
        (gButtonBar disable: (ScriptID 0 8)) ; icon5 that is.
        (= theTarget gSkipTarget)
        (= gSkipTarget null)
        (gRoom newRoom: theTarget)
      )
      (else
        ; Skip target is something to cue.
        (gButtonBar disable: (ScriptID 0 8))
        (= theTarget gSkipTarget)
        (= gSkipTarget null)
        (theTarget cue:)
      )
    )
  )
)

As an example, here’s the ladder-climbing sequence with Merrily:

(instance rm260 of LarryRoom
  (properties
    picture 260
    horizon 11
  )
 
  (method (init)
    (super init: &rest)
    (= gSkipTarget gRoom)
    ((ScriptID 0 8) enable:)
    (self setScript: toTower)
    ; ...
  )
 
  (method (cue)
    ; Called when we click the Fast Forward button.
    ((gRoom script?) setScript: forwardScript)
  )
)
 
(instance forwardScript of Script
  (properties)
 
  (method (changeState newState &tmp oldCursor)
    (switchto (= state newState)
      (
        (= cycles 2)
      )
      (
        (= oldCursor gCursor)
        (gGame setCursor: 999)
        (SetCursor 225 87)
        (if
          (Print
            addTitle: "Just Not Into Rubber, Larry?"
            addText: "Do you really want to miss out on what promises to be a unique experience, Larry?"
            addButton: 0 "Oops" 0 35
            addButton: 1 "Yes" 155 35
            init:
          )
          (self cue:)
        else
          (gGame setCursor: oldCursor)
          ; Reset the skip and get rid of this script.
          (= gSkipTarget gRoom)
          (self dispose:)
        )
      )
      (
        ; We chose to skip. Change up our inventory...
        (gEgo get: 40 put: 35 put: 31 put: 20 put: 2)
        (= gSkipTarget null)
        ((ScriptID 0 8) disable:)
        (gGame handsOff: changeScore: 20 174 hideControls:)
        (= cycles 2)
      )
      (
        (SetPort 0)
        (SetPort 0 0 190 320 10 0)
        (Bset 8)
        (gSounds stop:)
        (DrawPic 98 dpOPEN_EDGECENTER) ; Black screen
        (gCast eachElementDo: #hide)
        (= cycles 2)
      )
      (
        (gRoom newRoom: 620) ; Go to your room
      )
    )
  )
)

And then there’s The Dating Pool. It has a simple skip system with a single global, like LSL6, but comes in two parts like LSL5 and FPFP.

(instance SkipIcon of cdIconItem
  (method (select)
    (if gSkip
      ; I *could* ask for confirmation here...
      (gIconBar hide:)
      (if (IsObject gSkip)
        (gSkip cue:)
      else
        (NewRoom gSkip)
      )
      (return true)
    )
  )
)
 
(procedure (SetSkip skip)
  (= gSkip (if argc skip else 0))
  (if gSkip
    ; Unlike LSL6's ButtonBar, an IconBar's IconItem doesn't have enable or disable methods.
    (SkipIcon signal: (| icHIDEBAR icRELEASE icIMMEDIATE))
  else
    ; I could let gIconBar enable or disable the icon but nyeh.
    (SkipIcon signal: (| icHIDEBAR icRELEASE icIMMEDIATE icDISABLED))
  )
)

And an example:

(instance IntroScript of Script
  (method (changeState newState)
    (switchto (= state newState)
      (
        (HandsOff)
        (SetSkip skipScript)
        ; ...
      )
      ; ...
    )
  )
)
 
(instance skipScript of Script
  (method (cue)
    (DrawPic 150 dpFADEOUT)
    ; Put us at place we'd be if we let the cutscene play out.
    (gEgo
      init:
      posn: 90 130
      resetCycler:
      view: 0
      loop: 2
    )
    (gRoom setScript: RoomScript)
  )
)

(Update: I’d changed the skip script in The Dating Pool to use cue instead of doit and allow gSkip to be a room number. And then I forgot to update the example.)

[ , , , , ] Leave a Comment