- name
- machin-ressort
- description
- Build 2D games on the machin-ressort engine (MFL + raylib) — a Torque2D-style declarative scene/behaviour model over a Spring-style deterministic simulation with record/replay/verify. Use when writing or extending a ressort game, authoring a .rml level, adding a behaviour or a system, drawing with the text-sprite format, or debugging a replay that diverges. Also read this before hashing or doing bit-twiddling in MFL — it documents two overflow traps that silently corrupt results.
# machin-ressort
A 2D engine in MFL at `~/ai/machin-ressort`, plus a Demolition Man stage-1 POC.
Successor to [machin-gum-2d](https://github.com/javimosch/machin-gum-2d) (525 lines,
Vec2/verlet/particles); ressort is the ambitious one.
Read `README.md` first for the why. This is the how.
## The one rule
**Synced code may never touch the view. Unsynced code may never write the world.**
| synced (deterministic, headless, checksummed) | unsynced (raylib) |
|---|---|
| `engine/00_core 10_scene 20_tilemap 30_sim 40_scenefile` | `engine/50_ffi 60_view 70_forge 80_import 90_rig` |
| `game/10_defs 30_sim` | `game/20_art 40_view` |
Synced code must not call: raylib, `now()`, `now_ms()`, `rand_bytes()`, `GetFrameTime()`.
For randomness use `sim_rand(s,n)` / `sim_randf(s)` / `sim_randr(s,lo,hi)` /
`sim_chance(s,pct)` — they thread the PRNG that lives *inside* the world state.
The view reacts to the sim only through **events**: the sim calls
`sim_emit(s, EV_*, x, y, a, i)`, the view drains `s.events` each rendered frame. If you
find yourself wanting to mutate `s` from the view, the answer is a new event.
Break either rule and `demoman verify` stops meaning anything.
## Three binaries
**`bin/ressort` is the engine's sprite toolchain** (`tools/sprite.src`); **`bin/soldier` is
the playable pipeline demo** (`demo/soldier.src` — WASD + fire, runs on a forged fallback
figure when given no `--spr`, so no third-party art ships here); **`bin/demoman` is the
Demolition Man stage** (`game/*.src`). Both are ENGINE + one main. The tool works on
`.spr`/`.sil`/`.rig`/`.anm` files and knows nothing about any game's art table; the game
keeps `demoman art list|show|sheet` for its own sprites. Anything both need (flag parsing,
the JSON printers, `SCREEN_W/H`, `pal_default`) lives in the engine — `game/20_art.src`'s
`dm_palette()` is now a one-line alias over `pal_default()`.
## Build & verify
```sh
./build.sh # → bin/ressort + bin/demoman (vendors raylib 5.0 static into vendor/)
./build.sh test # headless engine suite, exit 0/1
./bin/demoman scene game/level1.rml # validate a level, JSON out
./bin/demoman sim --frames 9000 --trace --record run.rr
./bin/demoman verify run.rr # exit 0 reproduced / 90 diverged / 2 bad input
./bin/demoman shot out.png --at 600 # DISPLAY=:0; raylib writes to CWD, not the path's dir
```
`machin encode a.src b.src c.src` concatenates modules — that is the module system.
Order matters only for readability; the whole program is typechecked together, so the
engine can call the game's `game_step` hook by name.
Screenshot gotcha: `TakeScreenshot("docs/x.png")` writes `./x.png` — raylib strips the
directory. Move it afterwards.
Font gotcha: raylib's **default font is ASCII-only**. An em dash, `→` or `·` passed to
`hud_text`/`DrawText` renders as `?`, and you only find out by reading the screenshot
back. Keep anything drawn on screen ASCII; the docs can keep their typography.
## Adding an entity type
1. A `kind` code in `game/10_defs.src`.
2. A `prefab` line in the `.rml`, or an `entity` line for a one-off.
3. A branch in `game/30_sim.src` (a system, or a case in an existing one).
4. A sprite in `game/20_art.src` (forge it first — see below) and a branch in
`draw_entities`. Add the name to `art_names` / `art_by_name` in `game/40_view.src`
or it will not show up in `ressort sprite`.
Slots the **engine** owns on every `Ent` — do not repurpose:
| slot | owner | meaning |
|---|---|---|
| `s2` | `tm_move` | 1 = fall through one-way ledges this step |
| `s3` | `sys_physics` | 1 = suspend gravity (rope, ladder, zipline, death) |
| `t0` | `sys_lifetime` | countdown, `BEH_LIFETIME` only — never give the player that bit |
Everything else (`s0 s1 f0..f3 t1`) is the game's. Document which behaviour owns which.
## The scene format (`.rml`)
```
scene name=… gravity=… tile=32 seed=… # once
set key=value key=value # metadata; NO SPACES in a value
layer name=… depth=0..1 color=r,g,b y= h= kind=0..4
prefab <name> k=v … # a reusable row
spawn <prefab> at=cx,cy [overrides] # overrides win over the prefab
entity kind=… name=… at=cx,cy hw= hh= hp= beh=a,b,c
tiles
…rows…
end
```
- `at=` is in **cells**; the entity's feet land at that cell's bottom. Standing on a
floor at row R means `cy = R-1`. `px=x,y` overrides with raw pixels.
- `beh=` names resolve via `beh_by_name`; an unknown name is a **parse error**, not a
silent drop. So is an unknown prefab and a missing `tiles` block.
- layer `kind`: 0 flat band, 1 skyline, 2 lit windows, 3 smoke, 4 stars.
- tile legend: `.` air, `#` solid, `=` one-way ledge, `H` ladder, `T` ladder top
(standable + climb-through), `^` fire, `-` zip cable, `,` decor.
**Ladder topology.** A ladder joining an upper floor at row `RU` to a lower floor at row
`RL` is `T` at `(x, RU)` and `H` at `(x, RU+1 … RL-1)`. The actor standing on the lower
floor is then already *inside* the bottom rung, so `UP` grabs it, and `T` replaces the
upper floor tile so the climb passes through. Get this backwards and the ladder dead-ends
into a solid slab.
**Vertical clearance.** An actor `2*hh` px tall cannot fit a gap of `tile` px if
`2*hh >= tile`. With 32 px tiles, `hh=15` (30 px) passes a one-tile gap; `hh=17` (34 px)
does not, and every one-tile corridor becomes an invisible wall. This cost hours.
## Text sprites
```
func art_x() (s) {
r := []string{}
r = append(r, "..3333..") // '.'/' ' transparent, '0'-'9' then 'a'-'v' = palette 0..31
s = spr_anchor(spr(r), 4, 8) // anchor (ax, ay) in sprite pixels; feet = (w/2, h)
}
spr_draw(sprite, palette, x, y, pixel_scale, flip, tint) // flip = -1 mirrors
spr_draw_flat(sprite, color, x, y, scale, flip) // silhouette (shadows)
```
The palette is a `[]Color`. It **cannot** be a field of an MFL struct (cstructs can't be),
so pass it as an argument. `Sprite` is a plain MFL struct and caches fine — build them
once in `art_new()`, never per frame. (A slice of *plain* structs inside a struct is
fine — `Shade.mats []Mat` works; it is only the cstructs that cannot nest.)
## Making a sprite from a sentence
```sh
ressort proj init --name game --canvas 24x40 --assets assets
ressort sprite ask "a gunman in a long coat" --name gunman # -> a task, not a picture
# …write assets/gunman.sil AND assets/gunman.rig, in one go…
ressort sprite check gunman [--tone] # exit 0 / 90, writes the .spr when clean
```
- **`ressort.proj` is the style contract**: canvas, anchor, light, materials (which claim the
zone chars `#%@&+` in order), and the house skeleton. Every `ask` inherits it, which is what
stops a set of sprites from looking like a set of strangers. `--like <f.spr>` puts a
reference tone map in the brief for proportions.
- **Ask for the drawing and the rig in ONE reply.** Splitting them turns rigging into
archaeology on someone else's picture.
- `check` runs everything and answers once: canvas, connectivity (islands), anchor row, leg
gap, rig coverage, pose survival. **Every problem carries a `fix` in the format's own
vocabulary** — that is what makes an agent converge in one round instead of five.
- It reports **facts** separately from **problems** (extent, opaque count, largest mass, leg
gap, checksum). Facts are for judgement, problems are for correction.
- **It declines to judge what it cannot measure.** Below `POSE_MIN` (200 opaque px) the pose
check does not run and `pose_checked:0` says so: a two-pixel leg rotated 70° loses pixels to
nearest-neighbour sampling, and reporting physics as a defect teaches the caller to ignore
the checker. A checker that cries wolf is worse than none.
- **`ressort status`** — every asset, its state (`no rig` / `uncovered` / `flat rig` /
`stale spr` / `stale anm`) and the one command that advances it. Staleness is `stat` mtime,
so an `.anm` older than its `.rig` is flagged before the animation is trusted. This is the
project view; there is no GUI and there will not be one.
- **`ressort sprite compare a b …`** — tone maps in columns plus the facts; `--contact F.png`
for the human. How an agent picks among candidates without opening files.
- **`ressort sprite critique <name> --blind`** — the sprite with the request, name and palette
withheld, and one question. `ask` writes the request to `<name>.ask` so it can be kept back
until the cold reader has answered.
- Nothing here judges whether it *looks like* the request. That needs a cold reader — render
the tone map and ask a fresh agent to name what it sees, blind.
## The forge — do not hand-shade a sprite
`engine/70_forge.src` derives the expensive two thirds of a sprite. Draw a flat mass
and let it do the rest; only reach for hand-authored rows when the result is not good
enough, and then start from `--src`.
```sh
ressort sprite list # every sprite + palette indices + checksum
ressort sprite show <name> [--src]
ressort sprite mats # the material presets and their ramps
ressort sprite shade game/sil/drone.sil # flat drawing → shaded sprite
ressort sprite reshade <name> --mat navy # flatten a hand-drawn sprite, shade it again
ressort sprite anim <name> --kind walk|breathe|recoil|flinch|tumble --frames N
ressort sprite variant <name> --from bc --to hi
ressort sprite sheet out.png [--sil F] # the only verb here that needs a display
```
A `.sil` file is a drawing plus directives — `name`, `anchor ax ay`, `zone <char> <mat>`,
`light -1|1`, `mirror <overlap>`. Everything else in the file is a row of art.
- The shader does **form**, not material assignment. One zone character = one material,
so a character with skin, hair and a vest needs three zone characters (`#`, `%`, `@`).
- A character no material claims **passes through unchanged** — hand-place an eye or a
muzzle in the silhouette and the shader draws around it.
- Ramps are brightest-first. Depth below the top surface picks the entry; the rim
overrides on the lit edge and the top, occlusion overrides underneath and on the
shadow edge. Short ramps + `outline=1` on a narrow sprite = mostly outline.
- `sil_mirror` squares ragged rows first. It has to: otherwise every row mirrors around
its own right edge instead of the drawing's centre, and the sprite comes out lopsided.
- Animation transforms clip at the canvas edge — the margins in these sprites are the
budget. `spr_offset` moves the *anchor*, so a recoil costs nothing at all.
- `spr_sum` digests rows + anchor exactly as the sim digests the world. Pin new sprites
in `t_forge` and a shader change stops being invisible.
## Art from outside — `sprite import`
For anything above ~32x32 the forge's shader is the wrong tool; transcribe instead.
```sh
ressort sprite import x.png --name n --native 100 --out n.src # headless
ressort sprite import x.png --native 100 --compare c.png # original vs transcription
ressort sprite import x.png --native 100 --compare /dev/null --bench 24 # draw-call cost
```
- raylib is the PNG decoder only (`LoadImage`/`GetImageColor` are CPU-side — **no window
needed**; `--compare`/`--bench` do open one). `ImageFormat(img, RL_RGBA8())` first, or
`GetImageColor` reads whatever layout the file happened to carry.
- **32 colours is the hard ceiling** — the format addresses `0`-`9` then `a`-`v`. Measured
on a detailed 100x144 soldier, 32 is visually indistinguishable from 64 (RMS 7.3 vs 5.4)
and even 16 survives. The palette limit is not what will stop you.
- An imported character carries **its own palette** (`pal_<name>()`); `spr_draw` already
takes the table as an argument, so nothing about the format changes.
- **`--key auto|rrggbb` keys out a background colour.** Reference art is usually an
illustration on white with no alpha at all; without keying the whole canvas is opaque and
the bbox is the whole file. `auto` takes the top-left pixel. `--key-tol` defaults to 26.
- Integer upscales are found by boundary contrast (`step_score`). A non-integer scale has
no grid — the importer reports `detected_scale: 0` rather than guessing, and you pass
`--native WxH` to area-resample.
## Two-bone limbs
`leg_front` + `leg_front_lower` (`parent=leg_front`, pivot on the knee) — same for
`leg_back`, `arm_front`, `arm_back`. Roles 7–10; `rig_role` checks the `*_lower` prefixes
FIRST, since "leg_front_lower" also starts with "leg_front" and a shin must not be told it is
a thigh. Joints bend ONE WAY (the negative half of the sine is discarded, not mirrored) — a
hyperextending knee looks worse than none. `sprite check` reports a lower limb that is not
parented to its upper, because a shin pivoting at the hip swings the whole leg twice. This is
the single biggest visual win available: a one-box limb can only swing like a pendulum, which
is what makes a walk read as a march.
## Asking a human — `bin/ressort-taste`
```sh
ressort-taste hero.spr hero.rig --pairs 20 [--kind walk] [--base F.mot] [--amount 0.45]
ressort-taste hero.spr hero.rig --pairs 5 --auto # answered by fitness, for CI
```
- A window is right HERE and nowhere else in this toolchain: agent-facing verbs are headless
because an agent reads text; this one opens a window because a person watches motion.
- **Never ask about a pair that does not LOOK different.** `mot_distance` is the share of
pixels that differ frame-for-frame; pairs below `--min-diff` (0.12) are not shown. The first
session lacked this and came back 13-7 for the left-hand side — a position bias is what a
person produces when the content cannot decide it.
- Candidates are **styles**, not noise: six coarse axes (`amp knee lag arms bob lean`) at three
levels each, via `mot_restyle`. Two candidates differing by one level on one axis differ in a
way a person can describe out loud, which is what makes the data worth fitting.
- **Cap candidate reuse.** Pairs are chosen so no candidate appears more than twice, and a
ONE-axis difference is preferred — a single-axis verdict is the only attributable kind. A
session before that cap came back with the same loser in 10 of 12 pairs: twelve clicks
answering one question twelve times. `ressort-taste report <f.pref>` prints the per-axis
tally AND warns when the spread failed, because a readout that cannot say it is wrong is
worse than none.
- `ressort-evolve --runs N --out-dir D` gives champions from independent local optima;
`ressort-taste --bases a.mot,b.mot,…` makes them compete. Cross-lineage pairs are a different
question from cousins-of-one-base.
- **`role_limit` is the anatomy**, and both the styler and the search obey it. With a flat cap
the evolver leaned torsos as far as it swung knees and every champion walked tilted; with
per-joint limits the champions came out upright AND scored higher.
- `taste.pref` records the **seed**, not the motion (deterministic from base+seed), plus an
11-feature vector per candidate. Features are dimensions a person can NAME, so a fitted
model can be read back to them in their own words.
- Tags (`1`–`5`) describe what was wrong with the LOSER. Free text belongs in `taste.md` at
the end of a session, and comes to the agent — a sentence usually names a measurement the
fitness function does not have yet, which is where `poise` came from.
## The two holes a person found by watching
- **`tread`** — while a foot is DOWN it must travel backward relative to the direction of
travel. Every other term (`keep ground stride flow poise`) scores a reversed cycle
identically, which is how a search produced a man walking backwards. Asserted against a
reversed walk.
- **`lean` is SIGNED, and `upright` is its own term.** An absolute value cannot tell forward
from backward, so "wants less lean" meant "less of either" and the search leaned the body
to the joint limit. A figure drawn standing up should stay standing up.
- Related: `ressort-taste winner <pref> <spr> <rig> --out best.mot` hands back the candidate
a person actually picked, regenerated from the style the file recorded. It scores LOWER on
stride and tread than the search champion and looks better — so the weighting is still
wrong and the eye is still ahead of it. Say that out loud rather than shipping the number.
## Fitting a taste
Auf GitHub ansehen