A command does not need to become a new string every time you examine a word. Give this parser a pointer and a byte count. It finds the words, looks up their meanings and returns IDs, leaving the original input untouched.
The BASIC companion explains the grammar, error policies, world-object resolution and branches toward richer parsers. This version keeps that behaviour while choosing a representation that suits assembly: borrowed spans, static tables and an explicit state block.
Code status: maintained Z80 component with bounded CPU execution checks. Independent command rules check counted inputs, output reset, aliases, custom dictionaries and guard bytes. A separate ROM-loaded caller check exercises real paced keyboard input and editor overflow. Snapshot checks alone do not establish ROM input, contention or original-hardware behaviour.
Parser execution record · Keyboard caller checks
Try the command inspector
Build the sample folder with make, using Python and Asm198x on your path. Load build/demo.tap on a 48K Spectrum. Type TAKE LAMP and press Enter: the inspector shows status 0, verb 3 and noun 1. LOOK has noun zero, TAKE reports a missing noun, and a third word is rejected.
Delete edits the line. The submitted command stays visible until the next key starts another entry. At capacity, an extra character makes the border red and latches overflow. Enter then reports status 2 instead of executing a truncated prefix. Delete clears an overflowed entry so you can start again. Reset the machine to leave.
This caller demonstrates the parser and editor. The complete three-room adventure is in BASIC; the assembly library does not secretly include a room engine.

Capture from the original Code198x sample in Emu198x on the recorded 48K configuration.
Include, initialise, call
Include parser.inc once and the generated vocabulary tables once. Give the program a valid stack and writable parser state, then:
call parser_init
; Run this part once per submitted command.
ld hl,buffer
ld a,(input_count)
ld c,a
ld b,0
call parse_command
or a
jr nz,rejected
ld a,(verb_id)
; Dispatch an action. noun_id is a concept, not an object address.The snippet uses the editor’s buffer and input_count; supply equivalent storage and length in your own caller. The normal input has 0–63 bytes. HL points to readable memory; BC is its exact 16-bit count. The valid span must fit the address space without wrapping. Larger counts are rejected before reading input, including any count with nonzero B. No zero terminator is required, and the parser writes nothing to the input.
A returns status, mirrored in parse_status. verb_id and noun_id reset to zero on every call, including every failure. A recognised command returns status zero. A missing noun for a verb that takes none is represented by noun ID zero, not by an old value left over from another command.
| Status | Meaning |
|---|---|
| 0 | Recognised command |
| 1 | Empty or spaces only |
| 2 | Over 63 bytes |
| 3 | Unsupported byte |
| 4 | More than two words |
| 5 / 6 | Unknown verb / noun |
| 7 / 8 | Required noun missing / unexpected noun |
| 9 | Not initialised |
Initialise once at the start of a session, not before each command. parser_init clears the status and IDs, then sets readiness. Diagnostic scratch fields become meaningful after a parse; do not interpret uninitialised token state as a command.
Keep the input alive
word1_ptr, word2_ptr and their length bytes describe spans inside your input. They are cleared at the beginning of each parse; an early rejection can leave them empty. Their bytes keep the player’s original case. Lookup folds ASCII case during comparison rather than rewriting the buffer.
Those pointers are borrowed. If you replace the editor’s input, the old spans no longer describe the old command. Copy text you need to retain. Never pass a counted span to a zero-terminated printer without copying or respecting its length; there may be another command or unrelated memory immediately after it.
The grammar accepts ASCII letters, digits and ordinary spaces, folding lowercase to uppercase. It ignores repeated/leading/trailing spaces and uses exact aliases of 1–12 characters. Tabs, punctuation, quoted phrases and other bytes are rejected. The BASIC page explains the defined error precedence: validate the whole line before looking up its words.
The parser
Counted input, word spans and exact lookup
; Pasmo Z80. Caller: initialise a valid stack, then CALL parser_init once.
; parse_command: HL=readable input, BC=exact byte length (0..63).
; Input may contain ASCII letters/digits/spaces; it is never changed or terminated.
; HL+BC must fit the address space. A=status; verb_id/noun_id=0 on EVERY error.
; AF/BC/DE/HL change; IX/IY survive. All state below must be writable.
; Non-reentrant: interrupt code must preserve registers and not call this parser.
; Supply validated verb_vocab/noun_vocab tables (generate_vocabulary.py).
; Records: length, ID, noun-policy, uppercase bytes; length0 ends a table.
; Init is a lifetime/reset action, not a per-command action. No ROM/IRQ/I/O required.
parser_begin:
parser_init:
xor a
ld (verb_id),a
ld (noun_id),a
ld (parse_status),a
inc a
ld (parser_ready),a
ret
parse_command:
ld (input_ptr),hl
ld a,c
ld (input_length),a
xor a
ld (verb_id),a
ld (noun_id),a
ld (parse_status),a
ld (word1_length),a
ld (word2_length),a
ld de,0
ld (word1_ptr),de
ld (word2_ptr),de
ld a,(parser_ready)
cp 1
jp nz,parse_not_ready
ld a,b
or a
jp nz,parse_long
ld a,c
cp 64
jp nc,parse_long
or a
jp z,parse_empty
ld b,c
parse_validate:
ld a,(hl)
call upper_ascii
cp ' '
jr z,parse_valid_byte
cp '0'
jp c,parse_bad_char
cp '9'+1
jr c,parse_valid_byte
cp 'A'
jp c,parse_bad_char
cp 'Z'+1
jp nc,parse_bad_char
parse_valid_byte:
inc hl
djnz parse_validate
ld hl,(input_ptr)
ld a,(input_length)
ld b,a
ld c,0
parse_next:
ld a,b
or a
jr z,parse_words_ready
ld a,(hl)
cp ' '
jr nz,parse_word_start
inc hl
dec b
jr parse_next
parse_word_start:
inc c
ld a,c
cp 3
jp nc,parse_extra
cp 1
jr nz,parse_second
ld (word1_ptr),hl
ld de,word1_length
jr parse_word_byte
parse_second:
ld (word2_ptr),hl
ld de,word2_length
parse_word_byte:
ld a,b
or a
jr z,parse_words_ready
ld a,(hl)
cp ' '
jr z,parse_next
ld a,(de)
inc a
ld (de),a
inc hl
dec b
jr parse_word_byte
parse_words_ready:
ld a,(word1_length)
or a
jp z,parse_empty
ld b,a
ld hl,(word1_ptr)
ld de,verb_vocab
call lookup_word
or a
jp z,parse_unknown_verb
ld (candidate_verb),a
ld a,(word2_length)
or a
jr nz,parse_has_noun
ld a,(lookup_policy)
cp 1
jp z,parse_missing
jr parse_success
parse_has_noun:
ld a,(lookup_policy)
or a
jp z,parse_unexpected
ld a,(word2_length)
ld b,a
ld hl,(word2_ptr)
ld de,noun_vocab
call lookup_word
or a
jp z,parse_unknown_noun
ld (noun_id),a
parse_success:
ld a,(candidate_verb)
ld (verb_id),a
xor a
ret
parse_empty: ld a,1
jr parser_fail
parse_long: ld a,2
jr parser_fail
parse_bad_char: ld a,3
jr parser_fail
parse_extra: ld a,4
jr parser_fail
parse_unknown_verb: ld a,5
jr parser_fail
parse_unknown_noun: ld a,6
jr parser_fail
parse_missing: ld a,7
jr parser_fail
parse_unexpected: ld a,8
jr parser_fail
parse_not_ready: ld a,9
parser_fail:
ld (parse_status),a
ret
; Internal lookup. B=token length, HL=token, DE=validated table.
; Return A=ID or0, lookup_policy=matching record policy when ID is nonzero.
lookup_word:
ld a,(de)
or a
ret z
ld c,a
inc de
ld a,(de)
ld (lookup_id),a
inc de
ld a,(de)
ld (lookup_policy),a
inc de
ld a,c
cp b
jr nz,lookup_skip
push hl
push bc
push de
lookup_compare:
ld a,(hl)
call upper_ascii
ld c,a
ld a,(de)
cp c
jr nz,lookup_miss
inc hl
inc de
djnz lookup_compare
pop de
pop bc
pop hl
ld a,(lookup_id)
ret
lookup_miss:
pop de
pop bc
pop hl
lookup_skip:
push hl
ld l,c
ld h,0
add hl,de
ex de,hl
pop hl
jr lookup_word
upper_ascii:
cp 'a'
ret c
cp 'z'+1
ret nc
sub 32
ret
parser_code_end:
parser_ready: defb 0
parse_status: defb 0
verb_id: defb 0
noun_id: defb 0
input_ptr: defw 0
input_length: defb 0
word1_ptr: defw 0
word1_length: defb 0
word2_ptr: defw 0
word2_length: defb 0
candidate_verb: defb 0
lookup_id: defb 0
lookup_policy: defb 0
parser_end:AF, BC, DE and HL change. IX and IY survive. The longest parser path needs ten additional stack bytes, excluding its caller’s two-byte return address and any interrupt handler’s needs. The 16-byte state block must remain writable and exclusively owned.
The component is not reentrant. Do not call it from an interrupt while another call is active, and preserve the interrupted registers in your ISR. It makes no ROM, keyboard, display, sound, frame-wait or interrupt-mode call of its own. A port to another Z80 machine still needs checks for that environment; sharing a CPU does not establish an executed port.
Checked vocabulary tables
Edit the JSON, then rebuild:
Author-supplied words and IDs
{
"verbs": [
{
"word": "LOOK",
"id": 1,
"noun_policy": 0
},
{
"word": "L",
"id": 1,
"noun_policy": 0
},
{
"word": "GO",
"id": 2,
"noun_policy": 1
},
{
"word": "TAKE",
"id": 3,
"noun_policy": 1
},
{
"word": "GET",
"id": 3,
"noun_policy": 1
},
{
"word": "DROP",
"id": 4,
"noun_policy": 1
},
{
"word": "INVENTORY",
"id": 5,
"noun_policy": 0
},
{
"word": "I",
"id": 5,
"noun_policy": 0
},
{
"word": "EXAMINE",
"id": 6,
"noun_policy": 1
},
{
"word": "X",
"id": 6,
"noun_policy": 1
},
{
"word": "QUIT",
"id": 7,
"noun_policy": 0
},
{
"word": "HELP",
"id": 8,
"noun_policy": 0
},
{
"word": "WAIT",
"id": 9,
"noun_policy": 0
},
{
"word": "SEARCH",
"id": 10,
"noun_policy": 2
}
],
"nouns": [
{
"word": "LAMP",
"id": 1
},
{
"word": "LIGHT",
"id": 1
},
{
"word": "KEY",
"id": 2
},
{
"word": "NORTH",
"id": 3
},
{
"word": "N",
"id": 3
},
{
"word": "SOUTH",
"id": 4
},
{
"word": "S",
"id": 4
},
{
"word": "DOOR",
"id": 5
},
{
"word": "ROCK",
"id": 6
}
]
}Each verb alias requires word, id and noun_policy: 0 takes no noun, 1 requires one and 2 permits one. Each noun requires word and id. IDs are integers 1–255. There are 1–32 verb aliases and 0–32 noun aliases; counts refer to aliases, not distinct meanings.
The generator rejects duplicate spellings, conflicting policies for aliases of one verb ID, overlong/invalid words, booleans in integer fields and missing or misspelt fields. An invalid configuration cannot replace a previously generated output. The Makefile stops if generation fails.
The compiled representation is simple:
length, ID, noun policy, that many uppercase word bytes
...
0 end of tableNoun records use policy zero. Both table sentinels are included. Lookup requires equal lengths, so GETTER cannot match GET. The tables can be read-only; only the state block changes during parsing.
This is a deliberate difference from BASIC’s runtime DATA validation. The assembly routine assumes validated tables and does not return BASIC’s configuration status 10. Hand-editing or corrupting a record length breaks that precondition; use the generator rather than bypassing it. The generator source and Makefile are included in the sample folder.
Costs and choices
The recorded Asm198x build produces 342 code bytes and 16 writable state bytes for the parser. The supplied dictionaries occupy 157 bytes, including both sentinels. The editor, frame wait, font routines, stack and input buffer are additional costs.
There is no universal cycle count. Input length, alias order, misses and the number of lookups affect the work. A small linear table is easy to inspect and extend. A larger dictionary might justify sorted lookup or a prefix tree; that changes code/data costs and needs its own complete caller and measurements.
The point is not that assembly is automatically the right answer. BASIC makes the control flow and tables easy to change. This representation avoids its temporary word strings and runtime dictionary allocation, while making buffer lifetime and byte layout your responsibility.
A complete keyboard caller
Editor, parser and result display
; 48K PAL tape caller. ROM IM1 supplies the key latch; reset to leave.
; Editor capacity63 bytes. A rejected extra character latches overflow: Enter
; returns status2 instead of executing a truncated command; Delete clears it.
org $8000
start:
di
ld sp,$ff00
ld iy,$5c3a
im 1
ld hl,$4000
ld de,$4001
ld bc,6143
ld (hl),0
ldir
ld hl,$5800
ld de,$5801
ld bc,767
ld (hl),7
ldir
call parser_init
call clear_input
ld hl,title_text
ld b,1
ld c,0
call print_string
ld hl,help_text
ld b,3
call print_string
ld hl,output_text
ld b,12
call print_string
ld hl,legend_text
ld b,16
call print_string
ld hl,$cafe
ld (ready),hl
xor a
ld ($5c08),a
ei
main:
call wait_frame
ld a,($5c08)
or a
jr z,main
ld (pending_key),a
xor a
ld ($5c08),a
ld a,(new_line)
or a
call nz,clear_input
ld a,(pending_key)
cp 13
jr z,submit
cp 12
jr z,delete_key
cp 32
jr c,main
cp 127
jr nc,main
ld e,a
ld a,(input_count)
cp 63
jr nc,overflow
ld l,a
ld h,0
ld bc,buffer
add hl,bc
ld (hl),e
inc a
ld (input_count),a
call draw_input
jr main
submit:
ld a,(input_overflow)
or a
jr nz,submit_overflow
ld hl,buffer
ld a,(input_count)
ld c,a
ld b,0
call parse_command
jr show_result
submit_overflow:
ld a,2
ld (parse_status),a
xor a
ld (verb_id),a
ld (noun_id),a
show_result:
ld a,(parse_status)
ld b,13
ld c,0
call print_two_digits
ld a,(verb_id)
ld l,a
ld h,0
ld c,8
call print_three_digits
ld a,(noun_id)
ld l,a
ld h,0
ld c,15
call print_three_digits
ld a,1
ld (new_line),a
jp main
overflow:
ld a,1
ld (input_overflow),a
ld a,2
out ($fe),a
jp main
delete_key:
ld a,(input_overflow)
or a
jr nz,clear_editor
ld a,(input_count)
or a
jp z,main
dec a
ld (input_count),a
ld l,a
ld h,0
ld de,buffer
add hl,de
ld (hl),' '
call draw_input
jp main
clear_editor:
call clear_input
jp main
clear_input:
xor a
ld (input_count),a
ld (input_overflow),a
ld (new_line),a
out ($fe),a
ld hl,buffer
ld de,buffer+1
ld bc,63
ld (hl),' '
ldir
jp draw_input
draw_input:
ld hl,buffer
ld b,6
ld c,0
ld d,64
editor_cell:
ld a,(hl)
call print_char
inc hl
inc c
ld a,c
cp 32
jr nz,editor_next
ld c,0
inc b
editor_next:
dec d
jr nz,editor_cell
ret
ready: defw 0
pending_key: defb 0
input_count: defb 0
input_overflow: defb 0
new_line: defb 0
score_bcd: defb 0
buffer: defs 64,' '
title_text: defm "TWO-WORD COMMAND INSPECTOR"
defb 0
help_text: defm "Enter submits. Delete edits."
defb 0
output_text: defm "STATUS VERB NOUN"
defb 0
legend_text: defm "Try TAKE LAMP, LOOK, SEARCH KEY"
defb 0
include "parser.inc"
include "build/vocabulary.inc"
include "../../framework/game-loop/frame.inc"
include "../../rendering/character-printing/characters.inc"
include "../../rendering/numeric-display/numeric.inc"
end startThe caller supplies a 64-byte buffer: 63 input bytes and a spare displayed space. It reuses the maintained frame wait, character printer and number printer. Standard 48K ROM IM1 interrupts and IY system variables remain available. The ROM key latch is consumed once per frame; it is a latch, not a queue, so the verification types at a deliberate pace.
This executable owns the stack, display bitmap, attributes, key latch and port $FE output, including its border, MIC and beeper bits, and resets to leave. Those are the caller’s choices, not requirements of the parser library. Keep that distinction when putting the parser into a different program.
From IDs to an adventure
The returned noun is a concept. Your resolver finds objects of that concept that are visible or carried, handles none/one/several candidates, and passes the result to game rules. A recognised TAKE KEY can still fail because the key is elsewhere. A successful parse need not consume a turn.
For the complete BASIC game and detailed branches into aliases, noun phrases, clarification, prepositions, pronouns, lists and quoted text, see the companion’s expansion section. Those richer grammars are design options, not implemented features here. They need a structured result, clear ownership and meaningful failures rather than simply a larger input buffer.
The Adventure study and text-adventure genre give the wider context. This component does not claim to reproduce a historical game’s parser.
Verify your changes
The sample README gives the commands. CPU fixtures check independent command rules, stale outputs, counted spans crossing a page boundary, input/guard preservation, custom vocabulary and a deliberately broken extra-word check. The ROM-loaded inspector is exercised separately through actual keys, including overflow and recovery. Neither check qualifies arbitrary keyboard timing, interrupt coexistence or an entire composed game.