SCI/Specifications/SCI in action/Parser
The Parser
Vocabulary file formats
Originally written by Lars Skovlund
The main vocabulary (VOCAB.000)
The file begins with a list of 26 offsets. Each index corresponds to a letter in the (English) alphabet, and points to the first word starting with that letter. The offset is set to 0 if no words start with that letter. If an input word starts with an alphabetical letter, this table is used to speed up the vocabulary searching - though not strictly necessary, this speeds up the lookup process somewhat.
After the offset table are the actual words. A word definition consists of two parts: The actual text of the word, compressed in a special way, and a 24-bit (yes, three bytes) ID. The ID divided in 2 12-bit quantities, a word class (grammatically speaking) mask, and a group number. The class mask is used, among other things, for throwing away unnecessary words. "Take book", for instance, is a valid sentence in parser'ese, while it isn't in English.
The possible values are arranged as a bit field to allow for class masks, see later. Only one bit is actually tested by the interpreter. If a word class equals to 0xff ("anyword"), the word is excluded (allowing for parser'ese). The values go like this:
- 0x001
- Number (not found in the vocabulary, set internally)
- 0x002
- Special
- 0x004
- Special
- 0x008
- Special[1]
- 0x010
- Preposition
- 0x020
- Article
- 0x040
- Qualifying adjective
- 0x080
- Relative pronoun
- 0x100
- Noun
- 0x200
- Indicative verb (such as "is","went" as opposed to _do_ this or that, which is imperative)
- 0x400
- Adverb
- 0x800
- Imperative verb
The group number is used to implement synonyms (words with the same meaning), as well as by the Said instruction to identify words. There is also a way of using synonyms in code, see the appropriate document.
The compression works in this way: Each string starts with a byte-sized copy count. This many characters are retained from the previous string. The actual text comes after, in normal low-ascii. The last character in the text has its high bit set (no null termination!).
Here is an example of the compression scheme:
apple | 0,appl\0xE5 |
The byte count is 0 because we assume that "apple" is the first word beginning with an a (not likely, though!). 0xE5 is 0x65 (the ascii value for 'e') | 0x80. Watch now the next word:
athlete | 1,thlet\0xE5 |
Here, the initial letter is identical to that of its predecessor, so the copy count is 1. Another example:
atrocious | 2,rociou\0xF3 |
The suffix vocabulary (VOCAB.901)
For the following section, a reference to a grammar book may be advisable.
The suffix vocabulary is structurally much simpler. It consists of variably-sized records with this layout:
NULL-TERMINATED | Suffix string |
WORD | The class mask for the suffix |
NULL-TERMINATED | Reduced string |
WORD | The output word class |
The suffix vocabulary is used by the interpreter in order to parse compound words, and other words which consist of more than one part. For instance, a simple plural noun like "enemies" is reduced to its singular form "enemy", "stunning" is converted to "stun" etc. The point is that the interpreter gets a second chance at figuring out the meaning if the word can not be identified as entered. A word which changes its class does might end up as a different word class, the correct class is always retained. Thus, "carefully", an adverb, is reduced to its adjectival form "careful", and found in the vocabulary as such, but it is still marked as an adverb.
The suffix vocabulary consists of variably-sized records with this layout:
NULL-TERMINATED | Suffix string |
WORD | The output word class |
NULL-TERMINATED | Reduced string |
WORD | The allowed class mask for the reduced |
An asterisk (*) represents the word stem. Taking the above example with "enemies", the interpreter finds this record:
*ies |
0x100 |
*y |
0x100 |
Word class 0x100 being a noun.
The interpreter then tries to replace "enemies" with "enemy" and finds that word in the vocabulary. "Enemy" is a noun (class 1), which it is also supposed to be, according to the suffix vocabulary. Since we succeeded, the word class is set to the output value (which is, incidentally, also 1).
Numbers
If the word turns out to be a number (written with numbers, that is), and that number is not listed explicitly in the vocabulary, it gets an ID of 0xFFD, and a word class of 0x100.
The tree vocabulary (VOCAB.900)
This vocabulary is used solely for building parse trees. It consists of a series of word values which end up in the data nodes on the tree. It doesn't make much sense without the original parsing code.
The black box: The magic behind Sierra's text parser
Original document by Lars Skovlund. Document incomplete by the author.
This document describes the process of parsing user input and relating it to game actions. This document does not describe the process of the user typing his command; only the "behind-the-scenes" work is described, hence the title.
The process of parsing is two-fold, mainly for speed reasons. The Parse kernel function takes the actual input string and generates a special "said" event (type 0x80) from it. This function is only called once per line. Parse can either accept or reject the input.
A rejection can only occur if Parse fails to identify a word in the sentence.
Even if Parse accepts the sentence, it does not need to make sense. Still, syntax checks are made - see later.
Assuming that the parsing succeeded, the User object (which encapsulates the parser) then goes on to call the relevant event handlers. These event hand- lerrs in turn call the Said kernel function. This function is potentially called hundreds or even thousands of times, so it must execute as quickly as possible. Said simply determines from the pre-parsed input line whether or not a specific command is desired.
The Parse function must always work on an internal copy of the actual string, because the user must be able to recall his exact last input using the F3 key. The parser's first step is to convert the input line to pure lower case. This is because the vocabulary words are entered in lower case. The parser then searches the main vocabulary (VOCAB.000), hoping to find the word.
This doesn't necessarily happen yet. Consider, for example, the meaning of the word "carefully", which does not appear in the vocabulary, but is found anyway. This is due to the so-called suffix vocabulary, which is discussed in another document.
If the word still can't be found, the interpreter copies the failing word into a buffer temporarily allocated on the heap (remember, the interpreter operates on its own local buffers). It then calls the Game::wordFail method which prints an appropriate message. The interpreter then deallocates the buffer and exits (it does, however, still return an event. The claimed property of that event is set to TRUE to indicate that the event has already been responded to (error message printed)).
If the interpreter succeeds in identifying all the words, it then goes on to check the syntax of the sentence - it builds a parse tree. See the appropri- ate document.
If the syntax of the sentence is invalid, the interpreter calls Game::syntaxFail, passing the entire input line. As for the error situation, the event is claimed.
As mentioned in the beginning of this text, this function generates an event. This event, apart from its type id, does not contain any data. Rather, all pertinent data is kept in the interpreter.
The Said kernel function is called for each command which the game might respond to at any given time. Its only parameter is a pointer to a said information block which resides in script space. This block is described below (see the Said specs section).
The Said function first does some sanity checking on the event pointer which Parse stored earlier. It must be a said event (type property), and it must not have been handled by an earlier call to Said (claimed property).
It then word-extends the passed said block into a temporary buffer (command codes are byte-sized, remember?). This is supposedly just for convenience/speed, and not really needed.
The Parse tree
This and the two following sections borrow some ideas and structures from abstract language theory. Readers might want to consider related literature.
Most of the information explained here was gathered by Lars Skovlund, and, before that, Dark Minister.
After tokenizing, looking up, and finally aliasing the data found in the parsed input string, the interpreter proceeds to build a parse tree Tπ from the input tokens
- I := ω0, ω1, ω2 … ωn-1
where
- ωj ∈ W, with W being the set of all words;
- γj ∈ Γ, with Γ being the set of all word groups;
- μj ∈ 2C , with C being the set of all class masks {0x1,0x2,0x4,0x8,0x10,0x20,0x40,0x80,0x100}, and 2C being the set of all subsets of C;
- ωj = (γj, μj), with γj being the word group ωj belongs to, and μj being its class mask.
Note that elements of 2C (i.e. sets of class masks) can be identified with the ORed value of these class masks. So the set {0x2,0x4,0x80} can be identified with the value 0x86.
For the following sections, we define
- group: W → Γ : (γ,μ) ↦ γ
- classes: W → 2C : (γ,μ) ↦ μ
- Cx = {ω ∈ W | x ∈ classes(ω)}
To do that, it uses the class masks M as input for a pushdown automaton (PDA) A built from a parser grammar; if M was accepted by A, the parse tree Tπ will be built from the matching syntax tree to represent the semantics.
The PDA is defined by a grammar G = (V,Σ,P,s) most of which, along with its semantics, is stored in vocab.900. This resource contains a parser rule at every 20 bytes, starting with a non-terminal symbol υ (one word) and a null-terminated list of up to five tuples σi,mi , both of which are words. In these tuples, mi is a terminal or non-terminal symbol (determined by σi ), and σi is the meaning of mi :
σi | Type | Meaning |
0x141 | Non-terminal | Predicate part: his identifies the first part of a sentence |
0x142 | Non-terminal | Subject part: This identifies the second part of a sentence |
0x143 | Non-terminal | Suffix part: This identifies the third and last part of a sentence |
0x144 | Non-terminal | Reference part: This identifies words that reference another word in the same sentence part |
0x146 | Terminal | Match on class mask: Matches if mi ∈ classes(ωj) |
0x14d | Terminal | Match on word group: Matches if mi = group(ωj) |
0x154 | Terminal | "Force storage": Apparently, this was only used for debugging |
With the notable exception of the first rule, these rules constitute P. V := {x | ∃R ∈ P. x ∈ R}; typically, V = {0x12f,…,0x13f}⋅ s = m0 of the first rule encountered; in all games observed, it was set to 0x13c. Σ contains all word groups and class masks. For the sake of simplicity, we will consider rules matching composite class masks to be several rules. Here is a simplified example of what such a grammar might look like (the hexadecimal prefix '0x' is omitted for brevity):
In addition to this grammar, each right-hand non-terminal mi carries its semantic value ρi , which is not relevant for constructing a syntax tree, but must be considered for the semantic tree Tπ. These values were omitted in the example above. As in the example above, the grammar is a context-free (type 2) grammar, almost in Chomsky Normal Form (CNF) in SCI; constructing a grammar with CNF rules from it would be trivial.[2]
Obviously, G is an ambiguous grammar. In SCI, rule precedence is implied by rule order, so the resulting left derivation tree is well-defined (in the example, it would be defined by D0.[3]
Parser grammar example
G = ⟨ { 12f, …, 13e }, { C1, C2, C4, …, C100 }, P, 13c ⟩
P = { | 13c | → | 13b 134 |
13c | → | 13b 13d 133 | |
13c | → | 13b 13d | |
13c | → | 13b | |
13c | → | 13b 13d 13b 13d | |
13b | → | 131 134 | |
13b | → | 131 13d 13d | |
13b | → | 131 | |
13d | → | 134 | |
131 | → | C80 | |
133 | → | C20 | |
134 | → | C10 } |
Semantics
This is important, since the parser does much more than just accept or discard input. Using the semantic tags applied to each non-terminal on the right-hand side of a rule, it constructs what I will call the semantic parse tree Tπ , which attempts to describe what the input means. For each non-terminal rule
r = υ0 ↦ υ1υ2…υn
there are semantic tags σr,1,σr,2…σr,n ∈ S , as explained above. Tπ is now constructed from the resulting derivation and the semantic tags assiociated with each non-terminal of the rule used. The construction algorithm is explained below with Tπ being constructed from nodes, which have the following structure:
NODE = {◇} ∪ S × V × (NODE ∪ Γ)*;
Where S is the set of possible semantic values, and V is the set of non-terminals as defined in the grammar. We will also use the sequence γ0,&gamma1,&gamma2 … &gammak-1 which will represent the word groups the input tokens belonged to (in the exact order they were accepted), and the sequence r0,r1,r2 … rl-1 , which will be the list of rules used to create the left derivation tree as described in the previous section.
Helper function sci_said_recursive: S × V × (V ∪ Σ)* → NODE Parameters: s ∈ S, Rule r ∈ V × (V ∪ Σ): v0 → v1 v2 ... vi cnmr = cnr NODE n := s, v0 FOR j := 1 TO i IF (vj ∈ Σ) THEN n := n, γcnγ cnγ := cnγ + 1 ELSE cnoldr := cnr cnr := cnr + 1 n := n, sci_said_recursive(Σrmr,j, rcnoldr) FI ROF RETURN (n) Helper function get_children: NODE → NODE* get_children((s, v, n0, n1 ... nm)) := n0, n1 ... nm Algorithm SCI-SAID-TREE cnγ := 0; cnr := 1; ntemp := ntemp, SCI-SAID-RECURSIVE(0, r0) root(TΠ) := (141, 13f, get_children(ntemp))
Here is an example, based on the previous one:
Example: Parser example
Parse is called with "open door".
- "open" ∈ ⟨842,{C80}⟩ (an imperative word of the word group 0x842)
- "door" ∈ ⟨917,{C10}⟩ (a substantive of the word group 0x917)
- I = ⟨842,{C80}⟩,⟨917,{C10}⟩
I is clearly accepted by automatons based on the grammar described above, There are two possible derivations:
D0 = 13c | |
(13c ↦ 13b134) | ⊢ 13b 134 |
(13b ↦ 131) | ⊢ 131 134 |
(131 ↦ C80 | ⊢ C80 134 |
(134 ↦ C10 | ⊢ C80 C10 |
D1 = 13c | |
(13c ↦ 13b) | ⊢ 13b |
(13b ↦ 131134) | ⊢ 131 134 |
(131 ↦ C80 | ⊢ C80 134 |
(134 ↦ C10 | ⊢ C80 C10 |
Example: Semantic tree example
- k = 2
- γ0 = 842
- γ1 = 917
- l = 4
- r0 = 13c ↦ 13b 134
- σr0,1 = 141
- σr0,2 = 142
- r1 = 13b ↦ 131
- σ11,1 = 141
- r2 = 131 ↦ C80
- r3 = 134 ↦ C10
The resulting tree would look like this:
(141 13f (141 13b (141 131 842) ) (142 134 917) )
Said specs
To test what the player wanted to say, SCI compares Tπ with a second tree, TΣ , which is built from a so-called Said spec. A Said spec is a variable-sized block in SCI memory which consists of a set of byte-sized operators and special tokens (stored in the range 0xf0 to 0xf9) and word groups (in big-endian notation, so that they don't conflict with the operators); it is terminated by the special token 0xff. The meanings of the operators and special tokens are as follows:
Operator | Byte representation | Meaning |
, | f0 | "OR". Used to specify alternatives to words, such as "take, get". |
& | f1 | Unknown. Probably used for debugging. |
/ | f2 | Sentence part separator. Only two of these tokens may be used, since sentences are split into a maximum of three parts. |
( | f3 | Used together with ')' for grouping |
) | f4 | See '(' |
[ | f5 | Used together with ']' for optional grouping. "[a]" means "either a or nothing". |
] | f6 | See '['. |
# | f7 | Unknown assumed to have been used for debugging, if at all. |
< | f8 | Semantic reference operator (as in "get < up"). |
> | f9 | Instructs Said() not to claim the event passed to the previous Parse() call on a match. Used for succesive matching. |
This sequence of operators and word groups is now used to build the Said tree TΣ . I will describe the algorithm used to generate TΣ by providing a grammar GΣ , with L(GΣ) containing all valid Said specs. The semantics will be provided under each rule with a double arrow:
G\Sigma = ({saidspec, optcont, leftspec, midspec, rightspec, word, cwordset, wordset, expr, cwordrefset, wordrefset, recref}, \Gamma, P, saidspec) P := { saidspec \to leftspec optcont \Rightarrow (141 13f leftspec optcont) | leftspec midspec optcont \Rightarrow (141 13f leftspec midspec optcont) | leftspec midspec rightspec optcont \Rightarrow (141 13f leftspec midspec rightspec optcont) optcont \to e \Rightarrow | > \Rightarrow (14b f900 f900) leftspec \to e \Rightarrow | expr \Rightarrow (141 149 expr) midspec \to / expr \Rightarrow (142 14a expr) | [ / expr ] \Rightarrow (152 142 (142 14a expr)) | / \Rightarrow rightspec \to / expr \Rightarrow (143 14a expr) | [ / expr ] \Rightarrow (152 143 (143 14a expr)) | / \Rightarrow word \to \gamma \in \Gamma \Rightarrow (141 153 \gamma) cwordset \to wordset \Rightarrow (141 14f wordset) | [ wordset ] \Rightarrow (141 14f (152 14c (141 14f wordset))) wordset \to word \Rightarrow word | ( expr ) \Rightarrow (141 14c expr) | wordset , wordset \Rightarrow wordset wordset | wordset , [ wordset ] \Rightarrow wordset wordset expr \to cwordset cwordrefset \Rightarrow cwordset cwordrefset | cwordset \Rightarrow cwordset | cwordrefset \Rightarrow cwordrefset cwordrefset \to wordrefset \Rightarrow wordrefset | [ wordrefset ] \Rightarrow (152 144 wordrefset) wordrefset \to < wordset recref \Rightarrow (144 14f word) recref | < wordset \Rightarrow (144 14f word) | < [ wordset ] \Rightarrow (152 144 (144 14f wordset)) recref \to < wordset recref \Rightarrow (141 144 (144 14f wordset) recref) | < wordset \Rightarrow (141 144 (144 14f wordset)) }
Matching the trees
The exact algorithm used to compare Tπ to TΣ is not known yet. The one described here is based on the approximation used in FreeSCI, which is very similar to the original SCI one.
First, we need to describe a set of functions for traversing the nodes of TΣ and Tπ , and doing some work. They will be operating on the sets ℕ (all non-negative integral numbers), 𝔹 (Booleans), and NODE (which we defined earlier).
first: Node → S first((s, v, n0, n1 … ni)) := s
second : Node → V second((s, v, n0, n1 … ni)) := v
word : Node → &Gamma word((s, v, γ)) := γ
children : Node → Node∗ children((s, v, n0, n1 … ni)) := { m | ∀m.m∈{ n0, n1 … ni } ∧ m∈Node }
all_children : Node → Node∗ all_children(n) := children(n) ∪ { m | ∃l.l∈all_children(n).m∈l }
is_word : Node → B is_word((s, v, n0, n1 … ni) = tt ⇔ (i = 0) ∧ n0 ∈ &Gamma
contains_word : Node × S × &Gamma → B contains_word(n, s, γ) = tt γ = 0xfff[4] ∨ (⇔ ∃m.m∈all_children(n).(s = second(m)) ∧ (is_word(m) ∧ (word(m) = γ)))
verify_sentence_part_elements : Node × Node → B verify_sentence_part_elements(np, ns) = tt ⇔ (first(ns = 152) ∧ ((∀m.m ∈ Node.verify_sentence_part_elements(m, ns) ⇔ { w | ∃t.t ∈ all_children(m).w = word(t)} = ∅) ∨ ∃m ∈
children(ns).verify_sentence_part_elements(m, ns)) ) ∨ ((second(ns) = 153) ∧ (∃m.m ∈ children(ns).(∃o ∈ all_children(ns).(first(o) = first(np)) ∧ word(o) = word(m))) ) ∨ ((second(ns) ∈ {144, 14c}) ∧ (∃m.m ∈ children(ns).verify_sentence_part(m, ns)))
verify_sentence_part : Node × Node → B
verify_sentence_part(np, ns) = tt ⇔ ∀n.n ∈ children(ns):∃m.m∈children(np).(first(m) = first(n)) ∧ verify_sentence_part_elements(n, m)
verify_sentence_part_brackets : Node × Node → B verify_sentence_part_brackets(np, ns) = tt ⇔ (first(np) = 152 ∧ (∀m.m∈Node.(first(m) = first(ns)) ∧ (second(m) = second(ns)). verify_sentence_part(np, m) ⇔ { w | ∃t.t ∈ all_children(m).w = word(t)} = ∅)) ∨ ((first(np) ∈ {141, 142, 143}) ∧ verify_sentence_part(np, ns))
With these functions, we can now define an algorithm for augmenting Tπ and TΣ :
Algorithm SCI-AUGMENT matched := tt claim_on_match := tt FOREACH n ∈ root(TΣ) IF ((first(n) = 14b) ∧ (second(n) = f900)) THEN claim_on_match := ff ELSE IF ¬verify_sentence_part_brackets(n, root(Tπ)) THEN matched := ff HCAEROF
Augmenting succeeded if matched = tt; in this case, T&pi is one of the trees accepted by the description provided by T&Sigma. In this case, Said() will return 1. It will also claim the event previously provided to Parse(), unless claim_on_match = ff.
Notes
- ↑ The three special classes are apparently used for words with very specific semantics, such as "if", "not", "and" etc. It is unknown as of yet whether they receive special treatment by the parser.
- ↑ FreeSCI constructs a GNF (Greibach Normal Form) representation from these rules for parsing.
- ↑ In FreeSCI, you can use the ”parse” console command to retreive all possible left derivation trees.
- ↑ This is the so-called "anyword". Words with a word group of 0xfff match any other word.