Logo Pending


Menu Slowdown Mystery

Yesterday I had the idea to make a small “game” demonstrating various SCI11+ features, and instead of a main menu screen full of buttons I thought I’d use a proper menu bar. The kind you’d see in the old SCI0 games with the text parser and the low-resolution version of Leisure Suit Larry 6. Which incidentally is the only SCI11 game to have such a menu bar. The high-res SCI2 version’s menu bar is implemented entirely in script but the other one is 100% the same as in SCI0. If you were to copy the interpreter from any other SCI11 game over to LSL6 and run it you’d get an error saying AddMenu is not supported.

If you were to build your own SCI11, you’d find there are four targets; with the built-in debugger, with menu bar support, both, or neither. Most SCI11 games come with a “neither”, except for LSL6.

But this post’s title mentions slowdown. Why?

I defined two menu items, File and Topic. No Sierra logo menu here, not this time. One has About and Quit, the other lists the various features. As you do. But somehow when opening the second menu things would slow down just before it got to the last item, about the support for SCI32-style no-lookup font and color change control codes.

I thought maybe I’d messed something up when I cleaned up the source code, or when I added the thing where the menu bar is drawn in the same colors as the status line.

So I built an SCI11+ with all switchable hacks turned off. No dice, the menu was just black and white now but still slow. So that ruled out the extra features.

A “base” build that’s missing all new features? Nope.

Copy the terp from LSL6 and run that. Same deal so it’s not the cleanup either.

So I tried reorganizing the menu items. And I saw my mistake.

I had used a | to separate the Unicode and SCI32 control code items where it should’ve been :. For added insult, that’s the text formatting control code.

I wonder if AddMenu would work better as a variadic function 🤔

[ ] Leave a Comment

Perspective is a tricky thing

This topic was suggested, more or less, by Phil Fortier.

What do these screenshots from DoomLeisure Suit Larry 3, and Secret of Monkey Island have in common?

Their perspective. Every single wall is a straight line. I put Doom there to show it’s not just adventure games, and Monkey Island because the arcs end in straight lines, but otherwise they all have the same perspective. Don’t believe your eyes? Here, let me spell it out for you:

This is one-point perspective, where lines converge to a single point.

Here’s a Youtube video I picked out at random from my search results while I ensured I wasn’t pulling crap out of my ass. You’ll notice a hallway like that could do well as an adventure game background.

They’re also a pain in the ass when you render your game’s backgrounds with a program that doesn’t do 1PP, like I do. I mean, I could use this copy of 3D Studio Max that I have collecting dust over here, but all my prefabs are in Daz Studio? So I gotta fake it somehow. Very carefully align the camera so the walls point straight up.

In this old version of Alhor’s Garage in The Dating Pool, the walls are not straight. So I went back and tweaked the camera along with a few other details.

I feel much better about this version. But for other scenes, to get enough floor space in view, I have to pull back the camera drastically. Normally you’d increase the floor space by angling the point of view down. I’m sure you can agree that in Chairman Kenneth’s office, the camera is pretty far up. If I tried to reproduce that image in Daz, I’d get diagonal walls. So how do you fix that?

There’s practically no floor space here! If I used this, the main character would have a line to move along, and if other characters were to try and pass there’d be almost no space to show it. Moving the camera up mostly increases the ceiling space…

And of course you could fake it by tilting the walls back to compensate.

Or you can just say fuck it and deem the perspective distortion negligible after downsampling.

*sigh*

I seriously wish I had the means to acquire some nicely painted backgrounds, even after years of demos with rendered ones.

[ , , , , , ] Leave a Comment

File functions in SCI11 and SCI2 compared

Not to be confused with the other one.

In SCI11, we get the following FileIO functions:

(FileIO fileOpen name mode) Opens file name with the given mode and returns a handle.
(FileIO fileClose hnd) Closes the given file handle.
(FileIO fileRead hnd ptr size) Reads size bytes to the given address.
(FileIO fileWrite hnd ptr size) Writes size bytes from the given address.
(FileIO fileUnlink name) Deletes the given file.
(FileIO fileFGets ptr size hnd) Reads a string of up to size bytes to the given address.
(FileIO fileFPuts hnd ptr) Writes a string from the given address.
(FileIO fileSeek hnd where from) Seeks to the given location.
(FileIO fileFindFirst pattern ptr attr) Finds the first file matching the given pattern and copies its name to the given address.
(FileIO fileExists name) Checks if the given file exists.
(FileIO fileRename from to) Renames the given file.
(FileIO fileCopy from to buffer len)

Hmm. So, only null-terminated strings and arbitrary data blocks, huh? Surely you can do better? I wouldn’t want to have to do this every time I wanted to simply save a single 16-bit variable:

(= val gRating)
(FileIO fileWrite hnd @val 2)

Actual code from an earlier build of The Dating Pool, in case the name of the global didn’t tip you off.

Now, SCI2 added a whole bunch of extra options to the above:

(FileIO fileReadByte hnd) Simply reads and returns a single byte.
(FileIO fileWriteByte hnd val) Simply writes a single byte.
(FileIO fileReadWord hnd) Reads a single 16-bit word.
(FileIO fileWriteWord hnd val) Writes a single word.
(FileIO fileCheckFreeSpace ???) I’m not sure how this one works yet.
(FileIO fileGetCWD ptr) Was a separate call in SCI16.
(FileIO fileValidPath ptr) Checks if the given string is a valid path, I guess.

Wow. So what I did after learning these existed and looking back at the long-since scrapped persistent settings in The Dating Pool was, I added some of these to SCI11+. Specifically, the first four.

It was that or switch to using the File class exclusively and add wrapper functions for simple value reading and writing to it. But I can honestly say The Dating Pool doesn’t use File at all.

[ ] 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,chute,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,chute[<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,chute[<escape]' modifies the object it succeeds, while the [] around it make it optional. Thus, 'look/craft,chute[<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.

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

Save early, save… how?

This topic was suggested by Phil Fortier.

I can’t speak for the engines that came before, but saving and restoring a game in SCI11 is remarkably straightforward. The whole thing’s basically a dump of heap memory with some extra bits.

The hell is heap memory you might ask. Well, there’s two kinds of memory available to the actual game, provided by the engine, and those are heap and hunk. Every script whose objects or classes are used at a given time is loaded into heap space, most every other kind of resource goes into hunk space. In SCI11 specifically, scripts are split up into two parts, one with the actual code that goes in hunk space, and one with object definitions, local variables, and stuff like inline strings that goes on the heap. Correct me if I’m wrong on that one, Phil, cos I’m a little fuzzy on the details. (Update: I was.)

Save

When you save the game, the size of the heap and variables are written first, followed by the game’s version string. This part is why you can’t mix and match saved games between different versions of the same game. Then, since events are objects and thus in heap space, all pending key presses are flushed out. The engine source code explains this is to prevent stuff like re-restoring a just-restored game by having an F7 press in the buffer. Then all the unused space in the heap is zeroed out.

There’s a good reason for that. It’s because when the actual contents of the heap are saved, as one big blob, it’s compressed. Imploded, to be precise, almost but not entirely unlike how you’d make a ZIP file. And if you zero out the bits you don’t even use, it’ll compress much tighter. So a whole bunch of variables that are all packed together at a known location and the heap space are imploded and saved.

Then the plubus and grumbo are shaved away. Next, the state for any PalVary effects is saved, also in an imploded form. That there is any state to save at all is one of those variables I mentioned earlier.

The saved game’s file is closed, and the catalog file is updated. That being the ___SG.DIR file, which simply lists all the saved games’ names. The actual saves are those numbered ones, ___SG.000 and on.

Fun fact: the heap is about 64 kilobytes in size. Compression all but eliminates the unused, zeroed out parts. That leaves you with relatively small save game files. For example, this Dating Pool save is only 6203 bytes. But my unfinished shit is hardly representative… one of my Police Quest 3 games is 22.9 kilobytes.

Restore

Restoring a game starts out basically like the exact opposite of saving, exploding the variables and heap blob. Since all other resources are in hunk space, these are all unloaded at this point. Yeah, not a typo, I’m talking about the pictures and sounds and such that were already there. The hunk is cleared so that the stuff from the saved game can go there, obviously.

Throughout their lifetime, object properties and local variable values can and will change, of course (I’ll mention real quick that global variables are just the locals for script zero, which is never disposed of). Since those things are part of the heap, object and variable state is restored free of charge — all you really need to do manually is to get the code back. Given a list of supposedly-loaded scripts that should go in hunk space, again one of those variables, the restore routine can now iterate that list and repopulate the hunk.

At this point, PalVary state is handled if needed, the video mode is switched between Mode 13h and Mode X if it was different, and the PMachine is restarted. It knows we just restored a game, so the freshly-restarted PMachine knows to call the replay method instead of play. From that point, given a heap-restored state, the game can redraw its background at the time and enter the same endless doit loop as it would at the end of play. Any sounds, pictures, or views on the heap by now will have their resources loaded to hunk space as they are used.

Restart

Oh, didn’t expect that one! There’s this tricky pair of commands you can use in C, setjmp and longjmp, that to quote Wikipedia “provide control flow that deviates from the usual subroutine call and return sequence.” Right before the PMachine is spun up at the end of engine initialization, a sort of snapshot is taken to right that moment, consisting of all important CPU registers, including the one that determines where the next command will be. The SaveGame and RestartGame kernel calls use this to jump back in time and enter an all new PMachine loop. But what does restarting do exactly?

First, it sets the restart flag and disables PalVary. Jeez, does that subsystem start sounding hacky to anyone else or is that just me? Then it unloads every resource and resets the heap to one big block marked “free”. I didn’t say it zeroed out that block, because it doesn’t. Anything marked free is zeroed out while saving.

And the first thing the PMachine routine does? Get a handle on script 0, export 0 — the main game class. The last thing is to invoke its play or replay method, and since either of these methods end in a doit loop that doesn’t stop until gQuit is true, that invocation won’t return until then.

If C++ didn’t give me a headache, I’d look into how SCI32 may or may not be different from this. I know it has a bigger heap space, considering the whopping 117 kilobytes for a single save game from Love For Sail, but it’s the methodology that matters, right?

I suppose I owe you an answer to that question about the twenty game limit, don’t I? Well, I have a catalog file here listing about twenty-four so let’s see w–

[ ] 5 Comments on Save early, save… how?

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

Observations on Larry 6’s death handler

Though the two versions of Leisure Suit Larry 6 have a mostly identical script for its death handler, there are some interesting differences in the high-res SCI2 version that I’ve noticed:

  1. The reason parameter is stored into a local variable, which is used throughout instead.
  2. There is no checking if the rewindScript parameter is an object.
  3. Adding the title and text to the Print references the messages directly instead of preloading them:
    ; SCI11
    (Message msgGET 82 2 0 reason 1 @theMessage)
    (Message msgGET 82 2 0 reason 2 @theTitle)
    ; then
    addTitle: @theTitle
    addText: @theMessage theMessageX theMessageY
     
    ; SCI2
    addTitle: 2 0 lReason 2 scriptNumber
    addText: 2 0 lReason 1 theMessageX theMessageY scriptNumber
  4. At the start of the loop, the SCI2 version calls (DoAudio audPLAY scriptNumber 2 0 lReason 1). You might recall that’s the Message key for the main joke. It stops the audio, should it still be playing, when processing your response.
  5. Setting the window background is done a little differently; Print exposes a back property so it doesn’t have to memorize, change, and restore a global window background setting. There’s no such property in SCI11’s Print.
[ , ] Leave a Comment

Hold up, let me try that again.

In Leisure Suit Larry 6 – Shape Up or Slip Out, there are many ways to die, much like any other Larry game or indeed any Sierra game. Interestingly, this one has a “Try Again” button! How does that work?

Let’s work it out backwards. Starting from the script that handles the death message, we find it takes two parameters. One we can quickly determine to be the reason Larry died, the other we can tell is some sort of reference to a Script instance. After all, at one point the procedure checks if it’s an object in the first place, and in another it tries to cue the thing:

(while (not theAnswer)
  (Print
    font: gFont
    addTitle: @theTitle
    addText: @theMessage theMessageX theMessageY
    addIcon: frameIcon 0 0 2 0
    addIcon:
      (deathIcon view: theView cel: 0 loop: theLoop yourself:) 0 0
      theIconX
      theIconY
  )
  (switch
    (= theAnswer
      (Print
        addButton: 1 2 0 3 1 (+ theMessageX 1) theButtonY scriptNumber
        addButton: 0 2 0 2 1 (- ((Print dialog?) nsRight?) 75) theButtonY scriptNumber
        init:
      )
    )
    (0 ; Try Again
      (gLarryWindow back: prevBackColor)
      (gLSL6 setCursor: oldCursor)
      (gSounds eachElementDo: #pause false)
      (if rewindScript (rewindScript cue:))
      (= theAnswer -1)
    )
    (1 ; Restore
      (= local19 0)
      (gLSL6 restore: hideControls: drawControls:)
      (= theAnswer 0)
    )
  )
)

So there’s two things that can happen when you click “Try Again”. Either rewindScript is valid and it gets cue‘d before EgoDead returns -1, or it merely returns -1.

Taking a step back, we’ll look at one point where it gets called. In my case, opening the door to the swimming pool:

(instance enterPoolScr of Script
  (method (changeState newState)
    (switchto (= state newState)
      (
        (gGame handsOff:)
        (if (gMusic handle?)
          ; Pause whatever we're playing
          (gMusic pause:)
        )
        (gEgo
          setSpeed: 8
          view: 901 ; Grabbing the door
          loop: 6
          cel: 0
          setCycle: End self
        )
      )
      (
        (sfx
          number: 518
          loop: 1
          play:
        )
        (barDoor
          view: 5101
          setCycle: End self
        )
        (= ticks 10) ; Can you see the possible mistake here?
      )
      (
        (gEgo
          view: 5101 ; \o/
          loop: 2
          cel: 0)
      )
      (
        (= ticks 180)
      )
      (
        ; This is it. This is where we call the death routine.
        (EgoDead 13 self)
      )
      (
        ; "Try Again" was chosen, so we reset everything.
        (if (gMusic handle?)
          ; Unpause
          (gMusic pause: false)
        )
        (gEgo
          normalize: 900 8 1
          cel: 2
        )
        (barDoor
          view: 510
          loop: 0
          cel: 0
        )
        (gGame handsOn:)
        (self dispose:)
      )
    )
  )
)

So! You click the hand on the pool door, it animates a bit, calls EgoDead with the correct reason. That in turn recognizes reason 13, picks out the little animation and window color, fetches the right text from its Message resource (noun 2, verb 0, condition 13, sequence 1 and 2 for the joke and title respectively), and displays the window. If you click “Restore”, it keeps looping until you actually do restore something. If you click “Try Again”, it cues the caller (enterPoolScr in this case), which then sets it up like you never clicked the door in the first place.

And that’s how it works.

[ , ] Leave a Comment

Yeah? Prove it!

Ah, those gloriously outdated questions at the start of Leisure Larry 1 and 3. You gotta love ’em. But what if you wanted to implement your own, accounting for the simple fact that

  1. we don’t use Text resources any more now that we have Messages;
  2. bitfield magic is weird and unclear.

Couple years ago, I rolled my own. Actually I did so even further back, when we still used SCI Studio, but that was such an ugly hack I’d rather not talk about that. Anyway, here’s my own implementation. I’ll interrupt the source code to explain things as we go. Ready to prove you’re a learnèd adult?

;;; Sierra Script 1.0 - (do not remove this comment)
(script #140)
(include sci.sh)
(use Main)
(use Game)
(use System)
(use Print)
(public
  AgeCheckRm 0
)

Before we go on, you’ll need the following setup in a matching Message resource:

>

Noun Verb Cond Seq Text
0 0 1 1 Question 1
0 0 1 2 Answer 1-A
0 0 1 3 Answer 1-B
0 0 1 4 Answer 1-C
0 0 1 5 Answer 1-D
0 0 2 1 Question 2
0 0 2 2 Answer 2-A
0 0 2 3 Answer 2-B
0 0 2 4 Answer 2-C
0 0 2 5 Answer 2-D
1 0 0 1 Challenge
1 0 0 2 Correct
1 0 0 3 Wrong
1 0 0 4 Done
1 0 0 5 Cheater

Questions are phrased like this: "3Which of these is not a pokémon?" where the first character is a digit from 0 to 4. If the digit is 0, all answers are equally correct. If it’s not, 1-4 map to answers A-D. The total number of questions should equal the TOTALQUESTIONS definition that now follows:

(define TOTALQUESTIONS    20) ; How many questions you have messages set up for.
(define REQUIREDQUESTIONS  5) ; How many must be answered.
 
; Metrics. Depending on your image, you'll want to edit these to match.
(define QUESTIONLEFT     100) ; Where and how the question is written.
(define QUESTIONTOP       32)
(define QUESTIONWIDTH    150)
(define QUESTIONFONT       4)
(define QUESTIONCOLOR      0)
(define ANSWERLEFT       100) ; Where and how the first possible answer is written.
(define ANSWERTOP         60)
(define ANSWERWIDTH      170)
(define ANSWERSPACING     25) ; How much space goes between each answer.
(define ANSWERFONT         4)
(define ANSWERCOLOR        0) ; Black
(define CORRECTCOLOR      35) ; Green
(define WRONGCOLOR        12) ; Red
 
(define BLACKSCREEN        0) ; Picture # for the black screen. Questions use scriptNumber.
(define CORRECTSOUND     111)
(define WRONGSOUND       112)
 
(local
  questionsAsked = 0 ; Total number of questions asked
  currentQuestion = 0 ; Current question's index
  correctAnswer = 0 ; Current question's correct answer
  answerGiven = 0 ; Player's guess for the current question
  correctSoFar = 0 ; Questions answered correctly so far
; score = 0 ; If you were to put a lady in a swimsuit, this'd simplify things.
  [textBuffer 200]
)

The EndThis procedure is where you’ll want to do things like set a filth level according to the score and inform the player they’ll get to play at that level.

(procedure (EndThis)
  (gRoom newRoom: 120)
)
 
; Larry 1 and 3 use bitfield magic for this. We're keeping it simple, trading
; a bit of extra overhead for readability.
(instance AskedSoFar of List)
 
(procedure (PrepareQuestion &tmp i aTop [cleanQuestion 200])
  ; Try to find a question we haven't asked yet first.
  ; This might cause an infinite loop if the amount of questions is off.
  (while TRUE
    (= currentQuestion (Random 1 TOTALQUESTIONS))
    (breakif (not (AskedSoFar contains: currentQuestion)))
  )
  (AskedSoFar add: currentQuestion)
 
  ; Grab the question we picked and extract the correct answer.
  (Message msgGET scriptNumber 0 0 currentQuestion 1 @textBuffer)
  (= correctAnswer (- (StrAt @textBuffer 0) $30)) ; '2' - '0' = 2
  ; Copy the question *without* the first character (the answer) to our temp space.
  (for ((= i 0)) (< i (StrLen @textBuffer)) ((++ i))
    (StrAt @cleanQuestion i (StrAt @textBuffer (+ i 1)))
  )
 
  ; We can now display it.
  (Display @cleanQuestion dsCOORD QUESTIONLEFT QUESTIONTOP dsCOLOR ANSWERCOLOR dsBACKGROUND -1 dsWIDTH QUESTIONWIDTH dsFONT QUESTIONFONT)
 
  ; Now we can fetch and display the possible answers.
  (for ((= i 0)) (< i 4) ((++ i))
    (DrawAnswer i ANSWERCOLOR)
  )
)
 
; Draw a given answer (0-3) at the correct position and the given color.
; One thing you might want to try to do is to add the "a. b. c. d." bits.
; I left that out as a challenge.
(procedure (DrawAnswer number color)
  (Message msgGET scriptNumber 0 0 currentQuestion (+ answerGiven 1) @textBuffer)
  (Display @textBuffer
    dsCOORD ANSWERLEFT (+ ANSWERTOP (* (- number 1) ANSWERSPACING))
    dsCOLOR color
    dsBACKGROUND -1
    dsWIDTH ANSWERWIDTH
    dsFONT ANSWERFONT
  )
)
 
; Support function to keep the main part a little bit cleaner to read.
(procedure (TimedPrint theSequence theTime)
  (Print
    font: gFont
    ticks: theTime
    addText: 1 0 0 theSequence 0 0 scriptNumber
    init:
  )
)

Now we’re getting to the proper logic of the whole thing! First, we set things up much like you might a title screen, then pass control to a room script.

(instance AgeCheckRm of Room
  (properties
    picture BLACKSCREEN
  )
 
  (method (init)
    (super init:)
    (gOldMH addToFront: self)
    (gOldKH addToFront: self)
    (gIconBar hide: disable:)
    (gUser canInput: FALSE)
    (AskedSoFar init:)
    (HideStatus)
    (self setScript: RoomScript)
  )
 
  (method (dispose)
    (AskedSoFar dispose:)
    (gIconBar hide: enable:)
    (gOldKH delete: self)
    (gOldMH delete: self)
    (super dispose: &rest)
  )
)
 
(instance RoomScript of Script
  (properties)
 
  (method (changeState newState)
    (switch (= state newState)
      (0 ; Starting up
        (Prints 1 0 0 1) ; Give the challenge.
        ; At this exact point, the Larry games would ask for your age.
        (= cycles 1)
      )
 
      (1 ; Redraw the background, grab a new question, and wait for an answer.
        ; For the first question, transition nicely. For the rest, don't.
        (if questionsAsked 
          (DrawPic scriptNumber dpOPEN_NO_TRANSITION)
          ; (aSuit setCel: score forceUpd:)
        else
          (DrawPic scriptNumber dpANIMATION_BLACKOUT)
          ; (aSuit init:)
        )
 
        (PrepareQuestion)
        ; Sit and wait for a cue.
      )
 
      (2 ; Got an answer! Is it right!?
 
        ; First, redraw the given answer in either green or red.
        (DrawAnswer
          answerGiven
          (if (or (== answerGiven correctAnswer)
                  (== correctAnswer 0))
            CORRECTCOLOR
          else
            WRONGCOLOR
          )
        )
 
        ; Now, judge 'em.
        (++ questionsAsked)
        (if (or (== answerGiven correctAnswer)
                (== correctAnswer 0))
          (++ correctSoFar)
          ; (++ score) ; for a Larry 3 lady in a swimsuit
          (gMusic2 number: CORRECTSOUND play: self)
          (TimedPrint 2) ; Correct!
        else
          (gMusic2 number: WRONGSOUND play: self)
          ; (-- score) ; for a Larry 3 lady in a swimsuit
          ; For Larry 1 style, you might want to add a "was it wrong before"
          ; flag or counter. If we *were* wrong twice, exit the game.
          (TimedPrint 3) ; Wrong!
        )
        (= seconds 1)
      )
 
      (3 ; Wait a bit and repeat
        (if (== questionsAsked REQUIREDQUESTIONS)
          (gMusic1 fade:)
          (Prints 1 0 0 4) ; Done!
          (EndThis)
        else
          (= state 0) ; This actually makes state 1 so we get the next question.
          (= cycles 1)
        )
      )
 
    )    
  )

The handleEvent method will respond to lowercase A-D, uppercase A-D, Ctrl-Alt-X, and mouse clicks on the answers. If any of these things happen, it will cue itself, causing the answer to be checked.

(method (handleEvent event &tmp i aTop aBottom)
    (if (!= state 1)
      (super handleEvent: event)
      (return)
    )
    (switch (event type?)
      (evMOUSEBUTTON
        ; Check each answer spot in turn.
        (= aTop ANSWERTOP)
        (= aBottom (+ aTop ANSWERSPACING))
        (for ((= i 0)) (< i 4) ((++ i))
          (if (InRect (- ANSWERLEFT 10) aTop (+ (+ ANSWERLEFT ANSWERWIDTH) 20) aBottom (event x?) (event y?))
            (= answerGiven (+ i 1))
            (self cue:)
            (break)
          )
        )
      )
      (evKEYBOARD
        (event claimed: TRUE) ; Prevent the usual inputs from working.
        (switch (event message?)
          (KEY_a
            (= answerGiven 1)
            (self cue:)
          )
          (KEY_b
            (= answerGiven 2)
            (self cue:)
          )
          (KEY_c
            (= answerGiven 3)
            (self cue:)
          )
          (KEY_d
            (= answerGiven 4)
            (self cue:)
          )
          (KEY_A
            (= answerGiven 1)
            (self cue:)
          )
          (KEY_B
            (= answerGiven 2)
            (self cue:)
          )
          (KEY_C
            (= answerGiven 3)
            (self cue:)
          )
          (KEY_D
            (= answerGiven 4)
            (self cue:)
          )
          (KEY_ALT_x
            (if (& (event modifiers?) 4) ; Holding Control too?
              (TimedPrint 5) ; Cheater
              ; For Larry 3 style, you might want to ask what rating you want.
              (EndThis)
            )
          )
        )
      )
    )
  )
)
 
; Bonus lady in a swimsuit because why not.
;;; (instance aSuit of Prop
;;;   (properties
;;;     y 77
;;;     x 83
;;;     view 140
;;;     loop 1
;;;   )
;;; )

There may yet be some timing issues. I’ll leave fixing those to whoever’s brave enough to use this in the first place.

[ , ] Leave a Comment

Text, Voice… Both?

“Interesting. I wonder if this is related to the “BOTH” button that got cut in SQ4.” — @ATMcashpoint

I don’t know about the both button that ScummVM adds to some SCI games, but there’s quite literally no way it could work by just adding a third button state. There’s a fair bit of script logic that’d need to be overhauled. Here’s why that is, and here’s how I did it in The Dating Pool.

could have used the SCI Companion template game to compare against and document, but to be honest it’s a bit of a mess, as you could expect from a decompilation. The leaked system scripts are much neater to work with, even though the actual script code is basically identical.

Original Messager.sc:

(method (sayNext theMod theNoun theVerb theCase theSeq &tmp aTalker [theBuf 200] msgkey)
  ; If we were called with arguments, grab the text for that entry.
  ; If not, grab the next entry in the sequence.
  (if argc
    (= aTalker (Message msgGET theMod theNoun theVerb theCase theSeq @theBuf))
  else
    (= aTalker (Message msgNEXT @theBuf))
  )
 
  ; If we have voice enabled, allocate space and grab the entry's tuple.
  ; This block is missing in SQ4CD.
  (if (& gMessageType CD_MSG)
    (= msgkey (Memory memALLOC_CRIT 12))
    (Message msgLAST_MESSAGE msgkey)
  )
 
  (if (and  aTalker
            (or  (not lastSequence)
                 (and  lastSequence
                       (<= curSequence lastSequence)
          )
        )
      )
    ; Look up the Talker (or Narrator) by number.
    ; aTalker was a number, but now it'll be an object pointer.
    (= aTalker (self findTalker: aTalker))
 
    (if (!= aTalker -1)
      (talkerSet add: aTalker)
 
      ; Now let our Talker handle the rest.
      (if (& gMessageType CD_MSG)
        (aTalker
          modNum: theMod,
          say:    msgkey self ;<-- pass ONLY the tuple
        )
      else
        (aTalker
          modNum: theMod,
          say:    @theBuf self ;<-- pass ONLY the string
        )
      )
      ; In SQ4, we just always pass only @theBuf. There's some major
      ; voodoo involved in getting it to work.
 
      (++ curSequence)
    )
    ; Cutting a bit of irrelevant fastcast voodoo
  )
  ; Dispose of the space we allocated for the voice tuple, if needed.
  (if (& gMessageType CD_MSG)
    (Memory memFREE msgkey)
  )
)

Catdate’s Messager.sc:

; Exactly the same as in the template BUT...
(if (!= aTalker -1)
  (talkerSet add: aTalker)
  ; Pass both the buffer AND the tuple, no matter our settings.
  ; That does mean that msgkey may be null, but say won't use it in
  ; that case anyway.
  (aTalker
    modNum: theMod
    say: @theBuf msgkey
  )
  (++ curSequence)
)

In SQ4CD, the Narrator has extra noun, verb, and sequence properties that get set to allow the text to work. It’s really quite a bit of a mess, and my hat’s off to whoever on the ScummVM team got that Both mode to work. I was going to document it but got lost trying, it’s that wild.

On to the Narrator and by extension Talker!

Original Talker.sc:

(method (say theBuf whoCares)
  (if theIconBar (theIconBar disable:))
  (if (not initialized) (self init:))
 
  (= caller
    (if (and (> argc 1) whoCares)
      whoCares
    else
      null
    )
  )
 
  ; Figure out what to do with the message.
  ; Note that in one case, theBuf is a string...
  (if (& gMessageType TEXT_MSG)
    ; (method (startText theBuf &tmp strLength)
    (self startText: theBuf)
  )
  ; ...but in the other it's a tuple!
  (if (& gMessageType CD_MSG)
    ; (method (startAudio theKeys &tmp m n v c s)
    (self startAudio: theBuf)
  )
 
  ; cut a bit...
 
  ; start___ will have set ticks to the length
  ; of the string or recording. We add one more
  ; second regardless.
  (= ticks (+ ticks 60 gameTime))
  (return true)
)

Catdate’s Talker.sc:

; much the same, but
  (method (say theText theAudio whoCares)
    ; ...
    (if (& gMessageType TEXT_MSG)
      (self startText: theText)
    )
    (if (& gMessageType CD_MSG)
      (self startAudio: theAudio)
    )
    ; ...
  )

Now, this works fine. If I record a quick bit of gibberish, then load up the game, switch to Both, and click, I get a perfectly readable message and hear my gibber. But if I were to revert my little change and use the original code…

That’s what we in the business call mistaking a bunch of numbers for a valid string. I specifically get this result because the first value in the tuple is the module number, which is 110 (0x6E ‘n‘) in this case, and all numbers in SCI are 16-bit so there’s a terminating null right after.

What’s funny is that after all this, I can’t see how SQ4 is supposed to support Both mode, and ScummVM only needs to add that third button state. There is no patch to adjust the script, and I can’t for the life of me figure out how this would work:

(method (say theVoodoo whoCares &tmp newEvent)
  ; ...
  (if (& gMessageType TEXT_MSG)
    ; Note: noun, tVerb, and tSequence are properties. theVoodoo is now "case".
    (self startText: modNum noun tVerb theVoodoo tSequence &rest)
  )
  (if (& gMessageType CD_MSG)
    (self startAudio: theVoodoo)
  )
  ; ..
)

The weird part is that I can’t find anywhere those properties are set.

…At least with the KQ6/LB2 patches they actually do overhaul quite a bit of the scripts’ logic, which are otherwise just the same system scripts as above. Not the way I did it for my game, but clearly in a way that works out.

[ , ] Leave a Comment