Breaking words into recognized prefixes and unrecognized suffixes

The problem

One of the less-elegant features of Dialog is removable word endings. There’s a rather crude way you can specify endings to be optionally removed from words, and then if a word is not found in the game dictionary, the engine will try removing those endings and re-parsing. The results are unintuitive even to people who know how it’s supposed to work.

Linus proposed an alternative, way back when: a predicate that breaks a word into a recognized prefix and an arbitrary suffix, starting with the longest prefix. In other words, a multi-query with ARCHITECTUREXYZ would first split it into ARCHITECTURE and XYZ, then ARCHITECT and UREXYZ, then ARCH and ITECTUREXYZ, and finally A and RCHITECTUREXYZ.[1] A regular, non-multi query would only return the first one, ARCHITECTURE and XYZ.

This always seemed like a fantastic idea to me, but I was never comfortable enough with Dialog’s internals to attempt it. But now we’re about two years into the community-maintained fork, and I think I have a way.

A tangent

What Dialog calls a “dictionary word” is not actually the same thing as a Z-machine “dictionary word”, as seen in Inform. A Dialog “dictionary word” is actually an abstraction over three different data types:

  • A Z-machine dictionary word, stored as an index into the dictionary
  • An unrecognized word, stored as a list of characters
  • An extended word, stored as two pieces: the “required” part (a Z-machine dictionary word) and the “optional” part (a list of characters).

Extended words were created to avoid this situation on Z-machine:

> EXAMINE UNEMPLOYMENT POSTER
You don’t see any unemploym poster here.

Z-machine dictionary words are limited to nine characters, so if the player types something longer than nine character, it’s stored as an extended word: the first nine characters are the required part, and any further characters are the optional part.

Conveniently, when comparing dictionary words, only the required parts are compared. So UNEMPLOYMENT and UNEMPLOYM compare equal. This will be relevant later.

My solution

I propose something like this block of pseudocode.

def find_prefixes(word, output):
	let head = split_word(word) # produces a list of characters
	let curr = head
	while True:
		if curr == []: fail
		push_choice(0, next) # The next time we fail, jump to `next` instead
		curr = curr.tail
	
	def next(head, curr):
		pop_choice() # Remove the last choice point from the stack (i.e. the one we just used)
		
		firsthalf = split_list(head, curr)
		# If head is [a b c ... x y z] and curr is [x y z]
		# Then firsthalf is a new list [a b ... v w]
		# Thus [a b ... v w] [x y z] together comprise head
		# Note: curr must be a pointer into the head list! Not a separate list!
		
		output = make_extdict(firsthalf, curr) # fails if not possible

In other words, this would produce one or more extended words, each of which has the prefix as the required part and the suffix as the optional part.

This would all be implemented in Dialog’s IR, which means the implementation wouldn’t be set in stone. We could switch to a more efficient implementation at any time as long as the behavior remained intact, and any improvements would apply to Z-machine, debugger, and Å-machine alike.

It would, however, require one new IR instruction (and thus one new Å-machine instruction; the Å-machine hews close to Dialog’s IR):

MAKE_EXTDICT
value value dest

arg3 <- make_extdict(arg1, arg2)

# Takes a list of words and a list of characters
# Attempts to make an extdict, joining the first list into the required part, and the second list into the optional part
# If it cannot (i.e. the first list doesn't form a valid dictionary word), fails
def make_extdict(required, optional):
	req_word = join_words(required)
	if req_word.tag != dict: fail
	result = TOP
	TOP += 2
	heap[result] = req_word
	heap[result+1] = optional
	return <extdict, result>

We might also want an opcode that splits an extdict into its required and optional parts…or we could even build that into the behavior of MAKE_EXTDICT, by analogy with MAKE_PAIR (which both creates and splits lists).

So…

What do people think about this—both the concept and the implementation? Is this worth spending an opcode on?


  1. Assuming A, ARCH, ARCHITECT, and ARCHITECTURE are in the game’s dictionary. ↩︎

I think it sounds like a great idea!

It’s worth spending an opcode for this, even if it’s only to enable easily testing extended dictionary word semantics from Å-machine assembly. Since parsing would be more flexible too, then it’s all the better.

I hadn’t thought of the testing side. I agree that’s another good reason to do this!

On further reflection, MAKE_EXTDICT should take a dict and a list of characters, not two lists of characters. Just add a JOIN_WORDS to the first routine instead. Slightly more work (since it has to create the word before knowing if it’s valid or not), but since this is written in IR rather than written in the spec, we can optimize it later.

I’ll write up a proper spec for MAKE_EXTDICT tomorrow. It’s basically MAKE_PAIR with fewer special cases. There’s a convenient spot for it in the opcode table at $1A. (The only unclaimed slot in the $1X row!)

Here’s my proposal for a MAKE_EXTDICT opcode.

[[MAKE_EXTDICT]]
=== MAKE_EXTDICT

*DEST DEST DEST*

 if(arg3 is a store variant) {
 	if(arg1 is a store variant || arg2 is a store variant) fail
 	arg3 <- make_extdict(arg1, arg2)
 } else {
 	local variable a3 = deref(arg3)
 	if(a3.tag == reference) {
 		if(arg1 is a store variant || arg2 is a store variant) fail
 		unify(a3, make_extdict(arg1, arg2))
 	} else if(a3.tag == extdict) {
 		if(heap[a3.value + 0].tag == pair) { // Must be an extended word, not an unrecognized word
			fail
		}
 		arg1 <- <reference, a3.value + 0>
 		arg2 <- <reference, a3.value + 1>
 	} else {
 		fail
 	}
 }

 def make_extdict(required, optional):
 	local variable addr = TOP
 	TOP += 2
 	if(TOP > min(ENV, CHO)) runtime_error(1)
 	
 	local variable r = deref(required)
 	if(r.tag != dict) fail
 	heap[addr + 0] = r
 	
 	local variable o = deref(optional)
 	if(o.tag != pair) fail
 	heap[addr + 1] = o
 	
 	return <extdict, addr>

This implementation is similar to <<MAKE_PAIR>>, with a few key changes.
It uses the extdict tag instead of the pair tag, and some of its arguments must
be bound: either arg3 (an existing extdict to unpack) or both arg1 and arg2
(the parts of a new extdict to create). It is not valid to create an extdict
containing unbound references, and any attempt to do so will fail.

Does this seem like it would suit your purposes?

Hmm. Or would it be better to have two opcodes, since the “create a new extdict” and “unpack an existing extdict” modes are so different? I’m honestly not sure… MAKE_PAIR is kind of a special case, but an extdict really is like a pair…

In that case, there would be two opcodes:

PACK_EXTDICT ($1A)
VALUE VALUE DEST
Takes two bound values (which must be a dict and a list of chars) and makes an extdict out of them.

UNPACK_EXTDICT ($9A)
VALUE DEST DEST
Takes one bound value (which must be an extdict) and turns it into a dict and a list of chars.

This feels more elegant, even if it does take up two opcode slots. It’s also easier to implement and requires less checking at runtime.

I guess the deciding factor will be how people expect to use them. @AwesomeAdam12345, as the one person so far to make non-Dialog Å-machine files, do you prefer one over the other?

I’m now inclined toward the two-opcode option, because it makes the implementation much simpler. A lot of the versatility of MAKE_PAIR doesn’t exist here, because extdicts aren’t allowed to contain unbound values.

[[PACK_EXTDICT]]
=== PACK_EXTDICT

*VALUE VALUE DEST*

 local variable ess = deref(arg1)
 if(ess.tag != dict && ess.tag != char) fail
 
 local variable opt = deref(arg2)
 if(opt.tag != list) fail
 
 local variable addr = TOP
 TOP += 2
 if(TOP > min(ENV, CHO)) runtime_error(1)
 heap[addr+0] = ess
 heap[addr+1] = opt
 arg3 <- <extdict, addr>

An "extended" dictionary word has an essential part (a dict word) and an optional
part (a list of chars). This opcode turns those parts into a new extended word.

[[UNPACK_EXTDICT]]
=== UNPACK_EXTDICT

*VALUE DEST DEST*

 local variable word = deref(arg1)
 if(word.tag != extdict) fail
 if(heap[word.value + 0] != dict && heap[word.value + 0] != char) fail
 arg2 <- heap[word.value + 0] // essential: dict
 arg3 <- heap[word.value + 1] // optional: list of chars

See <<PACK_EXTDICT>>. This opcode only works on extended words, not unrecognized
words; unrecognized words also have the extdict tag, but their first element
is a list, not a dict.

Yep, I like the two-opcode option, it’s nice and simple.