Skip to content

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

Open
gitosaurus wants to merge 8 commits into
mainfrom
feat/list-message-dispatch
Open

Dispatch a list message on the message in its head#61
gitosaurus wants to merge 8 commits into
mainfrom
feat/list-message-dispatch

Conversation

@gitosaurus

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 fallback

Object::dispatch() asks the sent value for a message. If it hasn't got
one, it now asks the value's head. Every value that is not a pair has an
undefined head, so nothing that dispatched before dispatches differently:
['SHATTER' 3] -> vase reaches the default method exactly as it did when
the head named nothing.

['MOVE TO'] -> vase and 'MOVE TO' -> vase are the same send. There is
no separate argumentless case to write.

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

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.

Testing

testListMessages_ in TestObject.cc covers dispatch with an argument, the
identity of ['MSG'] with a plain message, an unclaimed head and an
unconvertible head both reaching the default method, the reply value, and
--> forwarding with arguments intact. testListLiterals_ covers the
operator table above; testPairs_ covers the string form.

18/18 suites. Both golden checks pass, and every compiled game is
byte-identical to main.

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 and silently routes a list message
to the default method. Ship the interpreter before any game that uses one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5

gitosaurus and others added 8 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
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