Skip to content

Give Miss Ring eyebrows, four more faces, and reactions that settle - #684

Merged
hahwul merged 1 commit into
mainfrom
hahwul/companion-mascot-more-expressions
Aug 14, 2026
Merged

Give Miss Ring eyebrows, four more faces, and reactions that settle#684
hahwul merged 1 commit into
mainfrom
hahwul/companion-mascot-more-expressions

Conversation

@hahwul

@hahwul hahwul commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Miss Ring's expression vocabulary is five cells wide — the cavity — and three of the four axes in it were already spent. The fourth was sitting there unused: the lashes. mascot.cr has always documented that ´ on the left with ` on the right lifts both inner brows (the soft, open look) and that swapping them reads as stern; nothing ever varied them. Now Mascot.brows(pose) does, in four leanings — soft, furrowed, and one brow cocked each way. A whole expression axis for no new glyph, no new cell and no font-coverage risk, and :error finally gets the furrowed brows it always wanted.

Derived from the pose, never carried on Frame: a brow field would have been the third member of the folded-field class the wink and shake comments describe.

Faces: 11 → 15

▐´●u●`▌ idle     ▐´●o●´▌ wonder   ▐`^u●`▌ wry
▐´^u^`▌ smile    ▐´·_●´▌ hmm      ▐`●n●´▌ pout      ▐`×_×´▌ error (brows added)

One new mouth glyph, nu turned over, same letterform, same x-height band, same fonts; the frown is the one shape the existing four cannot approximate. * eyes were tried and dropped: the asterisk sits at cap height in most monospace faces, the band the lashes occupy, which is exactly how the ˘ mouth shipped broken once already.

A reaction is an arc, not one frozen face

A mood is held 3–5s — 15 to 25 beats of an identical sprite — long enough that she reads as stuck rather than as reacting. She now hits the peak on the beat the result lands and settles into a quieter cousin of it 1.6s later: happy → smile, alert → hmm, error → flat. The peak staying at beat 0 is the half that matters (the settle is a tail on something the operator saw, never a different first impression), and it costs one extra frame change per note. It plays on calm too — "calm drops the rest" is about motion she starts on her own.

Gestures: 4 → 7, and 1/75s → 1/25s

The rate is sized against SLEEP_AFTER, not taste: she dozes after 90s, so at the old rate someone who stepped away saw a single gesture per waking spell.

Seven and not more, because the table is bounded by what the OPENING can show. The schedule is a pure hash of a beat counter that starts at 0 in every process, so the first firings are the same fixed sequence for every user of every build, and a spec pins that they cover the whole table. Ten firings fit in the sweep; enumerating the salt space against the real arithmetic:

scripts salts (of 1024) that cover the opening
6 213
7 111
8 16
9 0

Salt 918 opens on a clean permutation — all seven inside three minutes — and stays within ±1.7% over 25k firings.

Verification

  • Three mutation tests, each reding the spec that claims to guard it: revert the arc → the arc spec; salt back to 45 → the opening spec; brows out of the cavity → the face-uniqueness spec.
  • Live in a real terminal (tmux, 80s): smile, wonder, pout and wry all rendered at width 1, no coverage fallback.
  • Repaint budget after the retune: 98 changes in 449 beats (21.8%, ceiling 134). Winks 45 → 43 beats per 6000 — nothing specced that a wink ever appears until now, only where one may not, which is how raising the gesture rate could have taken them away silently.
  • crystal spec: 8607 examples, 8 failures — all the known environmental Gori::Session port-bind set. crystal tool format --check src spec bench scripts clean.

Noted deviation

just check's ameba step gains 2 findings: Mascot.mouth (14) and .eyes (15) against MaxComplexity: 12. The tree is already at 659 findings on main, .ameba.yml's own comment blesses inherently-branchy dispatchers, and the file's SPELL OUT EVERY POSE doctrine is what requires the arms — a pose missing one falls through to the idle face and renders as a gesture that never visibly happens. Happy to convert the three tables to constant lookups instead if you'd rather hold the bar.

Everything is still expressed in the cavity, so placement = bar shows all of it. Prose updated in four places: the Motion settings hint, settings/companion.cr, the settings guide, and CHANGELOG.

Her expression vocabulary is five cells wide — the cavity — and three of the
four axes in it were already spent. The fourth was sitting there unused: the
lashes. The art file has always documented that ´ on the left and ` on the
right lifts both inner brows (the soft, open look) and that swapping them
drops the inner ends and reads as stern; nothing ever varied them. Now
`Mascot.brows(pose)` does, in four leanings — soft, furrowed, and one brow
cocked each way — which buys a whole expression axis with no new glyph, no new
cell, and no font-coverage risk. The error face finally gets the furrowed brows
it always wanted.

Derived from the pose, never carried on `Frame`. A brow field would have been
the third member of the folded-field class the `wink` and `shake` comments
describe: `Mascot.cavity` is the only reader, so anything it did not honour
would be a Frame comparing unequal to its neighbour while painting identical
cells, and `#tick` would report a repaint the diff forwards nothing for.

Four new poses on top of it — a curious "oh?", a knowing half-smile, a
narrow-eyed "hmm", a sulk — 11 faces to 15. One new mouth glyph, `n`: it is `u`
turned over, the same letterform in the same x-height band, in the same fonts,
and the frown is the one shape the existing four cannot approximate. `*` eyes
were tried and dropped: the asterisk sits at cap height in most monospace
faces, which is the band the lashes occupy, and that is exactly how the ˘ mouth
shipped broken once already.

A REACTION IS NOW AN ARC. A mood is held three to five seconds — fifteen to
twenty-five beats of an identical sprite — which is long enough that she stops
reading as reacting and starts reading as stuck. She hits the peak on the beat
the result lands, then settles into a quieter cousin of it 1.6s later: beams
then smiles, tenses then considers, recoils then stares. The peak staying at
beat 0 is the half that matters — the settle is a tail on something the
operator saw, never a different first impression — and it costs exactly one
extra frame change per note. Unlike the idle tracks it plays on `calm` too:
"calm drops the rest" is about motion she starts on her own.

Idle gestures go four to seven, and one per 75s to one per 25s. The rate is
sized against SLEEP_AFTER rather than taste: she dozes after 90 seconds, so at
the old rate an operator who stepped away saw a single gesture per waking
spell and the table might as well have had one entry in it.

SEVEN AND NOT MORE, because the table is bounded by what the OPENING can show.
The schedule is a pure hash of a beat counter that starts at 0 in every
process, so the first firings are the same fixed sequence for every user of
every build, and a spec pins that they cover the whole table. Ten firings fit
in the sweep; enumerating the salt space against the real arithmetic, seven
scripts leave 111 of 1024 salts that cover it, eight leave four, and nine leave
none. Salt 918 opens on a clean permutation — all seven inside three minutes —
and stays within ±1.7% over 25k firings.

Everything is still expressed in the cavity, so `placement = bar` shows all of
it. Measured after the retune: 98 frame changes in 449 beats (21.8%, ceiling
134), and winks are untouched at 45 → 43 beats per 6000 — nothing specced that
a wink ever *appears* until now, only where one may not, which is how raising
the gesture rate could have taken them away silently.

Signed-off-by: hahwul <hahwul@gmail.com>
@hahwul
hahwul merged commit d803b39 into main Aug 14, 2026
6 checks passed
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