Skip to content

Dispatch a list message on the message in its head - #61

Merged
gitosaurus merged 12 commits into
mainfrom
feat/list-message-dispatch
Aug 19, 2026
Merged

Dispatch a list message on the message in its head#61
gitosaurus merged 12 commits into
mainfrom
feat/list-message-dispatch

Conversation

@gitosaurus

@gitosaurus gitosaurus commented Aug 13, 2026

Copy link
Copy Markdown
Owner

A message can now carry arguments:

['MOVE TO' player] -> vase

The head names the method; the whole list stays bound to message, so the
receiver reads its arguments with tail message and forwards them upward
with a bare message --> parent.

The feature is one rule

Dispatch always goes by the head. A list supplies its own, and — since
head <atom> now yields the atom — every other value is the head of
itself. Each half of the rule is a few lines:

  • Object::dispatch() asks the sent value's head for a message, with no
    fallback and no second case. ['MOVE TO'] -> vase and 'MOVE TO' -> vase
    are the same send, and ['SHATTER' 3] -> vase reaches the default method
    exactly as an unclaimed atom always did.
  • IValue::head() returns the value itself, so every value reads as a list
    of at least one. The tail stays undefined for an atom, on purpose: the
    tail is what tells an atom from a list of one, and what lets every loop
    that walks a list find the end.

A default method gets one spelling of the question, too: head message
names what was sent whether or not it arrived in a list. Before the head
rule, case message of matched bare messages and never a list's head,
while case head message of matched list heads and never a bare message —
a fork on exactly the distinction the feature set out to blur.

message deliberately stays the whole list rather than being rebound to
the head — that is what makes forwarding free.

system speaks the same rule

A list message to system is the staged dance in one send: the head
first, then each element of the tail in order, with the reply of the whole
being the reply of the last. So

['LOAD STATE' "file.acx"] -> system

keeps its Boolean with no state machine on the sending side; a control
message rides in a tail like any other element, so
['INIT SORTER' "b" "a" 'CLOSE SORTER'] is a whole open-feed-close
protocol in one send; and a one-shot can never be caught between states by
a save. The implementation is a walk: executeDefaultMethod feeds each
element to the old single-step logic, extracted unchanged as
interpret_(). figureState_ and the save format are untouched, and a
bare message is the one-element walk — an atom is the head of itself with
an undefined tail — so every staged protocol plays on exactly as before.

And intrptr.arch now speaks it. Every place that primed system and
then sent the argument on the next line asks the whole question at once —
five 'WHICH OBJECT' dances, the 'PLAYER CMD' pair, both 'BANNER'
pairs, and the save and load prompts, whose filename rides in the message
that wants it: if ['LOAD STATE' read] -> system. What stays staged,
stays for a reason: the vocabulary dances feed system from other
objects' 'NAME' methods, where each send's sender is the word's owner,
and the sorter is fed from a loop — genuinely many sends, which is what
the staged form is for.

Four things the feature turned up on the way

head and tail bound looser than every binary operator (precedence 2).
A prefix operator's precedence governs only what it swallows to its right,
so head tail message = UNDEFINED parsed as head tail (message = UNDEFINED)
and quietly asked whether the whole comparison had a head. They now bind at
12 with the other unary selectors — random, length, numeric, string
which is where they always belonged. The parentheses intrptr.arch was
carrying only to work around this are gone, except at line 541 where they
still separate a send from its operand and earn their keep.

Oracle for that change: Gorreven and Starship compile to byte-identical
.acx across it, which proves all nineteen head/tail sites in the
library parse the same as before.

write rendered any list as nothing at all — the one way of being wrong
that leaves nothing behind to notice it by. PairValue::stringConversion
now returns the display form, so write, &, and a debug trace all show a
list the same way.

Operators that care about structure now ask about it rather than
inheriting whatever the new string form implied:

Expression Before this branch Now
length [1 2 3] 7 (printed width) 3 (elements)
length (1 @ 2) 8 2
[1 2] < [1 3] lexicographic on text UNDEFINED
[1 2 3] = "[1 2 3]" TRUE FALSE
[1 2 3] = [1 2 3] TRUE TRUE (unchanged)
2 within [1 2 3] 4 UNDEFINED
2 leftfrom [1 2 3] "[1 " UNDEFINED
"items " & [1 2 3] "items [1 2 3]" unchanged

The rule: coercion answers "how do you read as text?" A test is needed only
when an operator's meaning depends on what the value is made of. length
counts, orderings can't order, and text surgery has no business inside a
list — within in particular stays free for a membership meaning later.
= and ~= were already structural (isSameValueAs runs before any
conversion); all that changed is that a list stopped comparing equal to its
own printed form by accident.

length walks the spine, the way std::list::size once did. An improper
tail counts: (1 @ 2) holds two things.

length [] is UNDEFINED, not 0, and stays that way for now. An empty
list terminates as UndefinedValue, which is also what an attribute nobody
ever set reads as — so answering 0 would claim that a missing attribute
contains zero things. Common Lisp gets (length nil) = 0 because its nil
wears two hats, empty-list and false, both of them values; Archetype's
UNDEFINED wears a third, missing. The loss is narrow: while lst do and
lst = UNDEFINED are the idioms, and neither needs a count. If a distinct
empty-list value is ever worth its own type, it must be false in a
conditional, or while lst do would spin.

The demo: a movement protocol that asks first

demos/moving.arch is the feature's honest showcase, and most of what it
shows is arguments being unnecessary. The shipped protocol in
intrptr.arch moves a thing by mutating first and telling it afterward, so
refusal means undoing — which is what last_location exists for. Asking
first fixes nearly all of that with no arguments at all: a precondition is
a method that is ABSENT for everything without an objection, and sender
already names the thing doing the asking. One thing is left over — a
precondition about the destination has to be handed the destination, and
sender is spoken for — and that is the file's only list message, costing
one attribute (dest_) to give the argument a name. The announced class
shows forwarding: message --> thing, whole list, nothing unpacked and
nothing rebuilt. The shipped 'MOVE' protocol is subclassed by the games
and is not going anywhere.

Testing

testListMessages_ in TestObject.cc covers dispatch with an argument; the
identity of ['MSG'] with a plain message; an unclaimed head, an
unconvertible head, and a bare unclaimed message all reaching the default
method, where head message names each of them; the reply value; and -->
forwarding with arguments intact. testListLiterals_ covers the operator
table above and the head rule itself: head 5 is 5, head [5] = head 5,
and tail 5 and head UNDEFINED are UNDEFINED. testListMessages_ in
TestSystemObject.cc covers the one-shot sorter with its last-echo reply,
['NEXT SORTED'] as the bare message, a control message riding in a tail,
and one-shot ['WHICH OBJECT' "grab"] with no priming and no second send.
One-shot ['SAVE STATE' f] and ['LOAD STATE' f] verified TRUE
end-to-end in the REPL.

18/18 suites. Both golden checks pass, regenerated for the intrptr
conversion: the Turtle — the reviewable half, the world state — did not
move by a line; only the statement bytes did. Gorreven was play-tested
through every converted path, including a save/load round trip and the
pronoun dance, with output identical to main's.

Compatibility

No new tokens and no new enumerators, so .acx files and golden binaries
are untouched — but that cuts the wrong way for forward compatibility: an
old interpreter finds nothing to fail on. It silently routes a list message
to the default method, and answers UNDEFINED for the head of an atom. And
since intrptr.arch itself now sends list messages, every game compiled
from this branch requires this interpreter — ship it first.

The version number now says so: this branch is Archetype 4.0, the
first bump since the version string was born saying 3.0 alongside the
C++ interpreter. It earns the major twice over — by the letter, because
head 5, length of a list, and orderings on lists all answer
differently than they did, and changed answers to old questions are a
major no matter how accidental the old answers were; and in spirit,
because "a 4.0 game needs a 4.0 interpreter" is the sentence a major
version exists to say. The .acx format version stays at 1: the byte
layout did not move, and that number describes the layout, not the
language.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P458PFzYW18hjwnzjmTAJj

gitosaurus and others added 12 commits August 13, 2026 13:35
['MOVE TO' player] -> vase now runs the vase's 'MOVE TO' method, with the
rest of the list along as arguments, read with "head tail message".  The
send site needed nothing: OP_SEND already hands the recipient whatever
value it was given, and a list rode through it intact -- only to fail
messageConversion() in dispatch and land in the default method.  So the
rule lives in Object::dispatch, where pass and every send in the
interpreter reach it too, rather than in the operator, where it would
have had to be written three times.

Nothing that dispatched before dispatches differently: head() is
undefined for every value that is not a pair, so the new conversion is
only ever tried where the old one already failed.  A list whose head is
no kind of message still falls to the default method, as it did.

"message" stays bound to the whole list, so a method can read its
arguments and still forward them with "message --> parent".  No new
tokens, no new enumerators, no change to the .acx layout -- the golden
files come out byte for byte identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5
They sat at precedence 2, looser than every binary operator, so a prefix
head swallowed whatever followed it: "head list = x" took the head of a
comparison and quietly handed back UNDEFINED.  The TODO left there asked
what the right precedence was.  It is 12, with length, numeric, string,
random and unary minus -- the family they belong to -- so that "head list"
is one operand and the operators around it see it that way.

Nothing in intrptr.arch changes meaning.  Its nineteen uses of head and
tail either end an expression, where a prefix operator's precedence
cannot matter, or were parenthesized already, which is the fossil of this
bug: the parentheses had to be written because the precedence was wrong.
Gorreven and Starship compile to byte-identical .acx files across the
change, so no expression in either parses differently.

"@" stays loose.  It builds rather than selects, and an element wants to
finish computing before it is joined on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5
Four sites wrapped "head x" only because head used to swallow whatever
came after it.  With head binding like a selector, the parentheses say
nothing, and leaving them in would teach the next author to write them.

Line 541 keeps its parentheses.  They group a send inside a cons, which
is a fact about "->" and "@" rather than about head, and a reader should
not have to recall which of those two binds tighter.

Gorreven and Starship still compile to the same bytes they did before the
precedence change, so none of these lines parses differently than it did
when the parentheses were there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5
"write" asks a value for its string, and a pair had none, so writing a
list put out nothing whatsoever -- a misrouted list message looked like
an empty string rather than like a list.  Of the ways to be wrong that is
the worst, since it leaves nothing behind to notice.

The string form is the printed form: what the REPL echoes and what a
message trace shows, so there is one rendering of a list and not two.
Its consequences follow the house rule that a value converts if it
sensibly can: "length [1 2 3]" is now 7, the width of the printed list,
exactly as "length 100" is 3, and lists order lexicographically by that
same text where before they did not order at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5
A string form for lists gave "length" and the orderings an answer they
had no business giving.  "length [1 2 3]" measured the printed width, 7,
and "[1 2] < [3]" ordered two lists by the text they render as, which is
an order in appearance only.  Both are the cost of coercion reaching
where it does not belong.

So they ask instead.  "length" of a list counts what is in it -- walked,
O(N), since the spine is the only record of how long a list is -- and an
improper tail counts as one of them, so "(1 @ 2)" is two.  Ordering a
list against anything yields UNDEFINED: there is no answer, which is not
the same as the answer being no.  Equality keeps asking what the list is
made of, as it always did, and no longer accepts a list and its own
printed text as equal.

"&", "write" and "string" go on coercing, because text is what they were
asking for in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5
"within", "leftfrom" and "rightfrom" were cutting up the form a list
prints as: "2" within [1 2 3] found the 2 at character four, and a list
sliced into "[1 " and "2 3]".  Answers to a question nobody asked.

"within" is also where membership would want to live, if lists ever want
a membership test, and an operator that already means something cannot
quietly come to mean something else.  UNDEFINED now keeps that door open;
character four would have welded it shut.

"&" is untouched, and still asks for the text it always wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5
The shipped protocol tells a thing about a move after the move; this one
asks first, because the destination rides in the message.  Nothing here
includes the standard library -- the protocol is the whole program -- and
nothing here touches the 'MOVE' protocol that shipped games subclass.

The thing class has no last_location and needs none: when ['MOVE TO' dest]
arrives, location is still the origin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5
A precondition that is ABSENT for everything without an objection covers
nearly all of this, at no cost to the common case, and 'ADD SELF' wants no
argument because the thing being added is the thing asking.  What is left
over is one question -- whether the destination will have it -- and the
destination is the one thing sender cannot name, since sender is already
saying which thing is moving.

So the demo now spends exactly one list message, and one attribute to name
its argument.  The vise is the other half of the point: bolted down needs
no argument at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5
Every value is now a list of at least one.  A bare message and
['MOVE TO'] were already the same silence to a handler asking for
arguments; now they are the same to everyone: dispatch always goes by
the head, and the second case in Object::dispatch() is gone.

A default method gets one spelling of the question, too.  "case head
message of" names what was sent whether or not it arrived in a list;
before, each kind of message needed its own case, because the head of
an atom was UNDEFINED.

The tail stays undefined for an atom, on purpose.  It is what tells an
atom from a list of one, and it is what lets every loop that walks a
list find the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P458PFzYW18hjwnzjmTAJj
A list message to system is the staged dance in one send:  the head
first, then each element of the tail in order, with the reply of the
whole being the reply of the last.  So

  ['LOAD STATE' "file.acx"] -> system

keeps its Boolean with no state machine on the sending side, and a
control message rides in a tail like any other element, so a whole
open-feed-close protocol fits in one send.

Bare messages walk the same walk in one step -- an atom is the head of
itself with an undefined tail -- so every staged protocol plays on
unchanged.  And a one-shot can never be caught between states by a
save.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P458PFzYW18hjwnzjmTAJj
Every place that primed system and then sent the argument on the next
line now asks the whole question at once:  five 'WHICH OBJECT' dances,
the 'PLAYER CMD' pair, both 'BANNER' pairs, and the save and load
prompts, whose filename now rides in the message that wants it --

  if ['LOAD STATE' read] -> system then

What stays staged, stays for a reason.  The vocabulary dances feed
system from other objects' 'NAME' methods, where each send's sender is
the word's owner, and the sorter is fed from a loop; both are
genuinely many sends, which is what the staged form is for.

The goldens moved by ten bytes of statements and not one line of
Turtle:  the world the games compile to is identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P458PFzYW18hjwnzjmTAJj
VersionString has moved exactly once before now: it was born saying 3.0
when the C++ interpreter was, and the number has only ever named eras.
This branch earns the next one twice over.  By the letter: head 5,
length of a list, and orderings on lists all answer differently than
they did, and changed answers to old questions are a major no matter
how accidental the old answers were.  By the spirit: a game compiled
against this library requires this interpreter, and "a 4.0 game needs
a 4.0 interpreter" is the sentence a major version exists to say.

The .acx format version stays at 1, deliberately: the byte layout did
not move, and that number describes the layout, not the language.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P458PFzYW18hjwnzjmTAJj
@gitosaurus
gitosaurus merged commit b9adce4 into main Aug 19, 2026
2 checks passed
@gitosaurus
gitosaurus deleted the feat/list-message-dispatch branch August 19, 2026 03:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant