Commit graph

61 commits

Author SHA1 Message Date
Admin
0b1249376e Arcade: the demo world becomes a splash template
The directive was that the demo scene become a project template you can make
new worlds from and edit by voice. This is the blocker gone: an authored
splash file can now build the world the demo builds.

`apps/arcade/resources/template.splash` is that world — sky, sun, terrain,
crossroads, two rows of houses, six distinct drivable cars, a bearded hero,
wandering townsfolk and the spiral climb — entirely in `game.*` verbs. It
loads: "eval ok, 28 entities, 5 skinned characters".

Two engine gaps had to close first, and both were the same shape: arcade
owned something the script could not reach.

**The script could not see the asset library.** `ScriptHost::set_assets`
existed and arcade never called it, so every asset verb — find_model, model,
kits, cast — reported "no stock library on this device" on a machine with
4,700 models on disk. arcade built an index for its own Rust demo and kept
it. An authored game therefore could not use any of the art the demo is made
of, which is most of the reason the demo had to stay in Rust. The index is
now built once, lazily, and shared with the host.

**Script characters had no bodies.** The skinned draw path walks
`self.villagers`, which only the built-in Rust world ever filled, so every
character in an authored game rendered as its bare collision box — which is
what a splash world looked like next to the demo. `sync_script_characters`
now builds that list from `blocks.characters` after each eval, matching the
model id the script asked for against the loaded cast and round-robining on a
miss. A wrong-looking character beats an invisible one.

Not yet done, and the template is honest about which parts are which: the
climb's moving lifts and the fireworks still have no verb, and the world is
written out longhand where the Rust version used loops.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:46 +02:00
Admin
6320c0bc68 Script: game.terrain uses the real generator; cars can carry a model
Groundwork for the demo becoming a splash TEMPLATE. Two things the script
surface could not express, so the demo had to stay in Rust.

**`game.terrain` carried its own single-octave value noise.** That meant the
terrain an AI could reach from splash was strictly worse than the terrain the
engine could make, and the two drifted independently — every fix to
`libs/game/gen/terrain.rs` (fBm, domain warp, world-unit frequency,
slope-aware colour, rim relief) was invisible to any authored game. It now
calls that generator. One generator, one set of bugs.

New params exposed: `feature` (distance between hills, in world units —
answerable, unlike "what is a good freq"), `octaves`, `warp`, `ridged`,
`flatten`, `rim`/`rim_start`.

Two compatibility decisions worth stating:

- `freq` still works. It meant cycles per CELL INDEX, so its wavelength in
  world units is span/((cells-1)*freq); translating rather than ignoring it
  keeps an existing world looking like itself.
- `step` now defaults to 0, not 1.0. The old default quantised every smooth
  slope into one-unit stairs, so a script asking for smooth terrain got a
  contour map. Scripts that want terraces still ask for them.

**`Car` now carries a model**, as `Character` already did, and `game.car`
takes `{model}`. Without it a host could only ever draw ONE kind of car —
which is exactly why the arcade fleet had to live in Rust, and why a
splash-authored world could not have more than a single vehicle shape.

The renderer prefers the car block's own model and falls back to walking the
`parked_car` role, so the same code path serves an authored game and the
built-in demo without the script needing to know the fallback exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:46 +02:00
Admin
67784be752 Town: a drivable fleet, two rows of houses, and a spiral climb
**Every car drives.** Six vehicles — ambulance, delivery, police, sedan,
taxi, delivery-flat — each a real chassis with a Car block and its own model,
so any can be walked up to, got into and driven away.

The logic is shared by construction rather than by copying, which is why one
car became six with no new plumbing: the interact prompt is DERIVED from the
presence of a car block, the mount is derived from the prompt, and the
renderer now walks every entity tagged "car" instead of finding the first.

Two asset-query lessons, both silent failures:

- `Spread::Variants` means "all from ONE family". Right for a terrace of
  houses, exactly wrong for cars — it returned six ambulances. `Spread::Kinds`
  caps at one model per family, which is what "one of each type" means. The
  query also has to name what the models are CALLED: "car vehicle sedan truck
  van" matched a single model.
- Asking for more house variants than the kit has houses returns LOT pieces.
  `building-type-o` is a swimming pool, and it duly appeared on the street at
  house height. A pool is a valid model that scales like any other, so
  nothing warns you.

The chassis now spawns `hidden` rather than being hidden later once a model
loads. That ordering made "draw a red slab inside the car" the default and
correctness an afterthought — with one car the afterthought fired, with six
it hid the first and left five driving inside red boxes.

That is a workaround, not a fix: `hidden` exists because an Entity is both a
physics body and a renderable, so every collision shape must opt OUT of being
drawn. A body should not be renderable at all.

The climb replaces the linear jump course: nine ledges spiralling a solid
tower, two timing platforms, an orbiting lift, a summit. Rise per ledge stays
inside one jump, the route is visible from the bottom, and falling costs
height rather than a life.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
14fb97e500 route: AI agent on by default again — perf campaign over; MAKEPAD_NO_AI=1 is the profiling opt-out
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
8d32736471 Fireworks: real shell scale, stars that drift instead of plummet, shell styles
"They don't fall this quickly." Correct, and the model was wrong rather than
mistuned: the fall term was 0.5*g*t^2, free-fall, as if each star were a
dropped rock. A star is a few grams of burning composition with a lot of
drag, so it reaches terminal velocity almost immediately and then DRIFTS.
Vertical motion is now quadratic for the first instant and a constant ~7 m/s
descent after — which is the hang every display has and ours did not.

Sized against reality, which our world happens to make easy: one unit is one
metre here (a character is 1.8 tall, houses 6-8). Real stars leave the burst
charge at 50-100 m/s and drag stops them in about a second, so the break
opens to speed/k across. At k = 3.08 the new 48-80 m/s gives a 30-52 m
diameter shell — a 3in to 6in break, what a town display actually fires. It
was 11-17 m before, which is why it read as a firecracker. Bursts moved up to
38-58 m accordingly; a real 3in reaches ~80 m, but ours stay lower so they
sit inside a camera that is pitched down at a street.

Shells are no longer all the same. A third are DUAL-COLOUR breaks, where half
the stars carry the second colour from the start rather than merely cooling
into it — the two-tone shell in every display photo. The split is per star and
stable, so a ray keeps its colour all the way out instead of shimmering. A
sixth are WILLOWS: stars thrown at half speed with a heavier drift, so they
arc over and trail down. The rest are plain peonies.

Style is chosen on the CPU, one float per shell, and applied entirely in the
splash-side `spark_color` — the engine still knows nothing about how any of
this looks.

Also dropped the leading-edge brightening: with uniform star speed there is no
longer a fast outer shell to distinguish, so it was tinting at random.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
2a42000f36 Fireworks: streaks are trains of dots, not stretched rects; sound removed
"Fireworks are streaks of PARTICLES, not a sphere expanded set of rects" —
and the reference photos show it plainly: every ray is beaded, a string of
glowing points strung along the path its star has flown.

So the model changed rather than the tuning. Each star is now a TRAIN of 8
beads, and a bead is simply that star's own closed form evaluated 40ms
earlier. Nothing extra is simulated and nothing extra is uploaded — the
trajectory was always a function of time, so sampling it at t - delay is free.
512 beads per shell is 64 stars with a real trail each.

Beads taper and dim toward the tail, so a ray has a bright head fading back
toward the burst centre, which is the shape every photograph shows.

I had built this as a stretched quad first — elongating the billboard along
the screen projection of the velocity. It is the standard trick and it is
wrong here: it draws one long rect per star, so the rays are smooth bars
rather than beaded, and a rect wide enough to see is also wide enough to look
like a slug. Removed.

The sprite is a round, ANTIALIASED dot. `smoothstep` rather than a linear
ramp, because a hard cutoff shows the rasteriser's stair edge on something
this small and bright, which is exactly where aliasing is most visible. Both
falloff terms reach zero at 0.8 of the half-width — inside the corners as well
as the edges — so the billboard border can never cut the dot. That was why
every spark read as a filled square.

Sound removed entirely, including the synth preset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
1509190fc0 Fireworks: a real peony — even star spacing, uniform speed, quieter bang
"Still a bit too random." It was, and the fix corrects something I took from
the wrong source last time.

Looked up how actual shells are built. Stars are packed EVENLY around the
burst charge and lit at the same instant, so they all leave with the same
force — that even spacing is precisely why a peony reads as round from every
angle. Two changes follow:

- Directions come from a FIBONACCI SPHERE instead of a per-spark hash. The
  golden angle steps phi so successive stars never line up, and z steps
  linearly so they spread evenly in AREA rather than in latitude (which
  bunches them at the poles). Hashed directions give clumps and holes, and no
  amount of extra sparks makes that look like anything but noise. A per-shell
  rotation keeps two shells from being the same object twice.
- Speed is near-uniform (6% jitter) instead of a 4:1 spread. I took that
  spread from the canvas demos last commit — but random(1,10) is a 2D trick
  for filling a disc. In 3D, identical stars igniting together travel
  together, and the spread just turns the sphere to mush.

The bang was also wrong: 900Hz of broadband noise at 0.34 gain is a shotgun
in a small room. A shell is heard from far away, so it arrives mostly low and
quiet — now a 220->38Hz thump at 0.085, falling away over 1.1s.

Sources: epicfireworks.com "The Art and Science of the Chrysanthemum Firework
Effect", liuyangfireworks.net "Ball Shell vs. Cylinder Shell"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
f7aa5f8297 Fireworks: symmetric bursts, longer and closer, with a bang
Reported as "too squiggly — real fireworks are more symmetric, they don't
move in big waves". They were right, and the cause was mine: the arcade
style's `spark_motion` added a curl and a jitter, so every spark travelled a
visible wave.

Read how the canvas demos actually do it (CreativeJS, the codepen/thecodeplayer
lineage). The answer is that they apply NO positional noise at all: one
uniform radial angle per spark, then nothing but friction and gravity. All
the shape comes from the SPEED spread, not from moving sparks around. Three
changes follow from that:

- `spark_motion` returns zero. The hook stays, because it is the right seam
  for a style that wants to be strange — a spiral shell, a jellyfish — but
  the default is symmetric.
- Drag matched to the convention: `speed *= 0.95` every frame at 60fps is
  exactly e^(-kt) with k = -60*ln(0.95) = 3.08, replacing a softer constant
  I had guessed.
- A 4:1 speed spread instead of 1.8:1. The demos use random(1,10); a narrow
  spread leaves a hollow shell with nothing in the middle.

Also reported: too fast and too far. Shells now live 3.2-4.6s (was 1.5-2.4),
throw sparks 17-27 units (was 11-19), and burst in a 25-46 unit annulus (was
out to 68).

The sprite is fully contained inside its quad. Its falloff dies at 0.8 of the
half-width, inside the corners as well as the edges, so the billboard's
straight edge can never cut the glow — which showed as square-clipped sparks.
The old cross-flare ran to the border and was the worst offender, so it is
gone. Colour is emitted unpremultiplied with zero alpha: pure additive light
under premultiplied blending, so sparks add and never occlude.

And they bang now. A shell reports its burst point exactly once, when its age
crosses zero, and the host plays a broadband noise burst there — positioned,
so it pans and attenuates like any other world sound. Noise rather than a
tone because a shell is broadband; a tone reads as a laser.

Sources: creativejs.com/tutorials/creating-fireworks,
thecodeplayer.com/walkthrough/canvas-fireworks-tutorial

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
362c96d63b Fireworks: parametric GPU sparks with a splash-side style seam (gated off)
CPU launches shells; the GPU animates every spark from a closed form. Once
a shell bursts nothing in the world can influence a spark — it flies from a
known point, at a known time, along a fixed arc — so position is a function
of (spark index, seed, age) and there is no state to step and nothing to
upload per frame. Twelve shells of 320 sparks is 3,840 particles for TWELVE
instances of CPU work, against 3,840 simulation updates and a 3,840-instance
upload for the stepped path.

**The style seam is the point.** Rust owns structure — trajectory, lifetime,
billboarding — and exposes four hooks a splash script overrides by
inheritance: `spark_motion` (swirl, fizzle, drift), `spark_size`,
`spark_color`, and `spark_pixel` (the sprite program). Arcade's own styling
lives in its `script_mod!`, not in Rust: a three-stage temperature burn,
hotter on the fast outer shell than the slow core, with sparse glitter
strobing. A generated game can restyle the sky without being able to reach
the simulation.

Two real bugs fixed on the way:

- **Vec3f instance fields silently misalign.** A `Vec3f` is tightly packed in
  Rust but a `vec3` obeys 16-byte alignment in the shader ABI, so the burst
  origin read back as zero and every shell rendered at the world origin, on
  the ground. The other shaders here get away with `Vec3f` because theirs are
  `uniform`, not instance. Everything is packed into `vec4`s now, which is
  the shape the hardware wants anyway and removes the class of bug.
- **Eight bare `panic!()`s in the shader backend** now name the backend, the
  stage and the IO type, and the commonest case — reading a geometry
  attribute from `pixel:` — gets a message saying so and showing the varying
  that fixes it. Previously a shader that tripped it aborted with no text at
  all, which is not a barrier, it is a wall in the dark.

**Gated off by default.** The spark SIZE instance is not reaching the shader:
scaling it 25x changes nothing on screen, so every spark draws as a
screen-filling blob and the sky whites out. That is the same class as the
Vec3f bug above and I have not found the second instance of it. The launcher,
the trajectory, the placement annulus and the whole hook surface are finished
and tested; this is one plumbing bug from working. ARCADE_FIREWORKS=1 turns
it on to work on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
3e7a66cc7d Ramps you can actually walk up, a crossroads, and a jump course
**The ramp was a solid cube.** Reported as "i dont seem to be able to walk
the character up the slanted block towards the platform" — and the wedge
had no collision handling anywhere. Movers sweep against static AABBs, so a
`Shape::Wedge` was collided as the box that CONTAINS it: the ramp built to
be walked and driven up presented a vertical wall at its low edge, and you
stopped dead against nothing you could see.

Wedges are now surfaces rather than walls, handled exactly the way terrain
already was — the symmetry is the point, since terrain had solved this
problem years earlier in the same file. They are excluded from the axis
sweeps, and a ramp floor pass sits underneath: walk up where the slope
rises less than CLIMB, blocked where it rises faster. That falls out
correctly at both ends without special-casing either — the gentle slope is
walkable, and the wedge's full-height back face is still a wall, because
there the surface jumps well past CLIMB in one step.

Sampled across the mover's whole footprint, not just its centre, so
standing with half your feet on a ramp stands you on the ramp.

**Conforming statics to terrain now ADDS the ground height instead of
replacing it.** Replacing looks equivalent, because everything is authored
resting on flat ground — right up until something is deliberately in the
air, at which point it flattens every platform, buried base and raised
ledge onto the dirt, and the failure reads as the level's fault rather than
the function's. Adding is a no-op on flat ground and rides the slope
elsewhere. Found by adding a jump course whose heights it ate.

**A crossroads and a side street.** One straight road reads as a corridor;
a junction is the smallest thing that makes a place feel like it has
somewhere else to be. The side street runs out to the yard, so the physics
corner is somewhere you drive TO rather than somewhere that is merely
nearby.

**A jump course**: a static step to read the route from, a platform that
slides across your path, one that rises and falls, and a wide still ledge
that is obviously the end. Gaps are sized against the controller's actual
jump distance rather than eyeballed, and the two movers run on different
periods so they drift in and out of phase instead of presenting the same
crossing every lap. The ramp's high edge is the run-up, which is what turns
two separate toys into one thing to do.

Three tests, one per claim: a character walks up, the back face still
stops them, and standing on the slope reports on_floor (without which the
controller silently refuses to jump). The first version of the walking test
passed for the wrong reason — its world had no ground, so the walker fell
past the ramp and met it from BELOW, where being blocked is correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
3544d92593 Arcade: bearded player, fixed sun, wheels under the wheels
**A guy with a beard.** The player wears the barbarian, pinned BY NAME
rather than by cast index — the cast is assembled from whatever the asset
library contains, so an index silently becomes a different person the
moment a pack is added, and that failure reads as a cosmetic surprise
rather than a bug.

Kenney was the obvious cheaper choice and it does not work: every character
GLB in both Kenney packs has only root/torso/head/arm/leg parts, and their
colormap is flat colour swatches with no painted faces, so there is no
bearded Kenney character to pick. Verified by rendering the heads rather
than by guessing from filenames.

**The sun is fixed.** It swept a full day every 40 seconds, which looked
lively for about ten seconds and then just cost money: AO and cast shadows
are baked, and the baker rebakes whenever the sun crosses
`sun_rebake_angle`, so nothing on screen ever settled. Now one explicit
direction at 38 degrees elevation — the engine default sits at 54, nearly
overhead, where shadows barely clear their own footprint and nothing reads
as standing on anything. At 38 a shadow runs about 1.3x its caster's
height: long enough to describe the shape and show the ground's slope,
short enough that the village does not vanish into its own shade. The day
cycle survives behind ARCADE_DAYCYCLE, where it belongs until rebaking is
incremental.

**The simulated wheels now sit under the drawn ones.** Reported as the car
"not really following the landscape, wheels not really touching". The
config's track and wheelbase are authored for a generic chassis while the
mesh is scaled from whatever the pack shipped: for the stock truck the
drawn wheels sit 1.30x wider and 1.39x further apart than the simulated
ones, with a 1.33x radius. On flat ground that is invisible, which is
exactly why it survived a flat-ground test measuring a 3e-6 residual — the
model's lowest point still lands on the road. On a slope it is not
invisible: the body pitches and rolls about the SIMULATED contact points,
and a drawn wheel further out swings through a bigger arc, so it lifts off
the ground. Adding terrain is what made it visible.

Fitted from measured bounds at model-load time, the same discipline as
sitting the mesh on measured bounds rather than on the collision box.

I first read "floaty" as suspension bounce and stiffened the springs. That
was the wrong problem; reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
082af7e4d7 Arcade: un-invert the camera stick
Reported as "the camera joystick feels y inverted", and it was.

A pad reports the stick's UP as +y. `look_dy` is in SCREEN units, where y
grows downward and `+look_dy` raises the camera and looks down. The stick
value went in unnegated, so pushing the stick away from you looked down
while pushing the mouse away from you looked up — two devices disagreeing
about which way is up, on the same camera.

The test states the rule as agreement between the devices rather than as a
raw sign. A sign assertion is satisfiable by flipping whichever end of the
chain you happened to be reading, and this chain has three sign conventions
in it (pad, screen, render pitch); what actually has to hold is that the two
physical "push away from me" gestures do the same thing.

`ARCADE_INVERT_Y` still restores the old behaviour, which is now what it
always should have been: a preference, not the default by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
fcfd335f1e Terrain: a real heightfield under the world, and cars that touch the road
**The flat plane is gone.** Terrain existed all along — heightfield collision
via box3d, AO and shadow raymarching against it, a mesh path, a
`game.terrain` verb — and arcade simply spawned a flat slab instead. That is
the fourth capability this week that was built and never called.

Turning it on was not enough, because the generator had faults that a
screenshot explains faster than prose:

- **Single-octave value noise.** Detail at exactly one scale reads as melted
  blobs. Now fBm with domain warp, which is the single highest-value knob
  here: warping the sample point bends contours into ridges and valleys
  instead of round lumps on a visible grid.
- **`step` defaulted to 1.0**, quantising every smooth slope into 1-unit
  stairs. The old default renders as a literal contour map. Off by default.
- **Noise was indexed by CELL INDEX, not world position**, so asking for a
  finer mesh silently generated a different landscape. Resolution should buy
  detail, never a new world.
- **Colour came from height alone**, which paints terrain in horizontal
  stripes like a contour map. Now height AND slope, so rock lands on cliff
  faces and grass on the shelf above them.

Generation moved to `libs/game/gen/terrain.rs` as a pure function. Beyond
testability that was forced: arcade's demo world has no script VM, so the
only generator in the tree was one it could not reach.

`rim_relief` is the load-bearing idea. Terrain interesting everywhere is
terrain you cannot put a town on; terrain flat enough to build on is a green
table. Growing the relief outward gives a playable basin ringed by something
worth looking at, and doubles as a soft boundary. It SCALES the noise rather
than adding a radial ramp — the ramp version has no noise in it and renders
as a smooth machined ring between two flat plains, which I built first and
threw away after looking at it.

Two things the tests taught me rather than confirmed:

- Normalising fBm by the sum of octave amplitudes — the textbook form — makes
  five octaves come out FLATTER than one, because summing decorrelated fields
  concentrates them about the mean. Normalising against the field's own
  extents makes `amp` mean literal relief at any octave count.
- The octave test measures CURVATURE, not slope. At gain 0.5 / lacunarity 2
  every octave contributes equally to slope — that is what self-similar
  means — so a slope-based test reports no difference while the terrain
  visibly gains detail. My first version of that test was wrong, not the code.

Statics get one conform pass after composition; movers already clamp to the
terrain every tick in `step.rs`, so the player, villagers and car find the
ground themselves.

**Cars now touch the road.** Kenney authors vehicles origin-at-the-contact-
patch — tyres exactly on y=0, with each wheel node lifted by its own radius —
and every vehicle in every kit measures min.y == 0, verified across all 4442
GLBs in the library. We were dropping the model by `half.y` instead, a rule
that is right for a walker (a Mover's box bottom really is its feet) and
wrong for a raycast vehicle, whose suspension probes from the chassis origin.
The float was suspension travel plus wheel radius, 0.341 units, predicted in
closed form and matched by simulation to 1e-5. The same line also shifted in
world Y after rotation, so the mesh slid out from under a leaning chassis.
Both now derive from measured bounds along the body's own down axis — no
constant anywhere. Note the convention is real but NOT universal: track and
road pieces go to min.y = -1.0, so it must be read, never assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
bb156b2ee6 Arcade: draw the HUD, pull the walking camera back, build headless again
**The HUD was never drawn.** `libs/game/render::hud` has existed and
gamemaker has called it all along; arcade simply never did. So the
interact prompt was computed every tick, published to `world.hud_slots`,
and thrown away — the get-in-a-car mechanic was discoverable only by
guessing the key. Same shape as the gamepad never being polled: the
capability was there and the app never called it.

Two things found while wiring it:

- The prompt asked for `size: 1.0`, which reads like a scale but is an
  absolute point size to the renderer — any value above zero is taken
  literally. It would have drawn a one-point speck. Now 17.0, deliberately
  above the 12.0 default, since this is the one line a player has to
  notice without being told to look.
- Added a standing controls hint. Nobody sits a child down with a manual,
  and the affordance prompt only appears once you are already next to
  something; this is what tells you how to get there.

**Arcade could not build headless.** The gamepad poll was added without
the `cfg(headless)` split gamemaker uses, and arcade had no `build.rs` to
define the cfg at all, so `MAKEPAD=headless` failed to compile and the
render-to-PNG path went with it.

**The walking camera sat too close** — at 6.5m the character filled a
fifth of the frame and hid the world behind them. Now 9.0m.

That broke a test asserting `car.distance > on_foot * 1.5`, and the fix
is the test, not the number. That ratio read like a decision but was
really the quotient of two values that happened to be current, so
correcting the walking boom on its own merits broke an assertion about
driving while nothing about driving had changed. Restated as a margin:
what has to hold is that getting into a car visibly widens the view, and
several metres does that at any walking distance.

Also logs gamepad connect/disconnect on change, so "the pad does nothing"
is answerable from a release log — either the app never saw the device or
it saw it and the mapping is at fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:30:42 +02:00
Admin
8be3e4c561 Arcade: third-person player rig, activity button, gamepad wiring
A game gets a walkable character that can get into a car and drive it
in three lines and never mentions a camera:

    let id = Character::new(...);
    player_rigs.insert(PlayerId(0), PlayerRig::new(id));

Everything crafted is engine-side with defaults nothing has to specify:
coyote time, jump buffering, variable jump height, asymmetric accel and
decel, air control, landing recovery, a boom that snaps in and eases
out, look-ahead, speed pullback, and delayed recentring.

Script surface grows by three verbs (115 -> 118): game.player_character,
game.interactable, game.interact_prompt. Cars and doors-with-interiors
are derived affordances, so a generated game with a car and a house
declares nothing; game.interactable is only for chests and switches.
The prompt and the press share one search so they cannot disagree, and
it picks the nearest candidate in front rather than merely the nearest.

Four bugs found by looking at what ran, not by reading:

- Arcade never polled the gamepad at all. No game_input_states() call
  existed anywhere in the app, so the pad's state never entered the
  process and every binding downstream read a struct nobody filled.
- LT drove both brake and negative throttle, and brake force opposes
  reverse motion. Measured: clean reverse covers 13.8m in 2s against
  22.25m forward; with brake held, 0.63m. car.rs is unchanged -- a foot
  on the brake winning is a car behaving like a car.
- Mount cleared `hidden`, which means "solid to everything, drawn by
  nothing" -- so boarding left the driver as an invisible collider at
  the kerb. Now uses attached_to, the sim's seat pin, and saves and
  restores hidden rather than asserting a value.
- GameWorld::new() never set gravity; only reset_content() did. All 70
  new() call sites floated, and four files had each independently grown
  their own `world.gravity = 30.0`. A floating character never reports
  on_floor, so the controller silently refused to jump.

The in-vehicle boom goes 9.0 -> 13.0. The boom is a time budget, not a
length: at the car's top speed 9m was 0.37s of road ahead, too little to
plan a turn. The test states it against CarConfig::top_speed, so raising
the car's speed fails the test instead of quietly making the view tight
again. The pivot deliberately does not rise with it -- eye.y is
pivot.y + sin(pitch)*boom, so the longer boom already buys the height,
and driving should sit lower and more planted than walking.

Player rigs now survive Blocks::clear(): where you are sitting and where
you are looking are the player's state, not the game's content, so a
script edit no longer ejects the driver mid-corner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:38:05 +02:00
Admin
d4c9392912 Baked AO for static props, the big world wired, and steering fixed at its source
AO — ZERO EXTRA BYTES. It lives in the alpha byte of the packed colour lane,
which was already dead weight: model.rs wrote the glTF baseColorFactor alpha
there, the skinned shader multiplied it into v_tint.w, and the pixel shader
threw it away by returning a hardcoded 1.0. We were paying for the channel and
never reading it. 24 bytes/vertex before and after.

The multiply scales AMBIENT ONLY — `albedo * (ambient * ao + direct)`. Folding
AO into direct as well would darken a sunlit wall twice, and direct light is
already zero where a surface faces away, which is precisely where occlusion is
the ambient term's job.

Static-only holds BY CONSTRUCTION without touching skin.rs: that file writes
pack_unorm8x4(1,1,1,1), so characters get ambient * 1.0 — an exact no-op
through the shared shader.

Cost over the real catalogue (4,442 models, 2.5M verts): 2.64 ms/model average,
102 ms worst case — down from 409 ms. Dense interior kits get a reduced ray
budget, and the hemisphere distributes over the ACTUAL ray count rather than
the nominal one; without that fix a reduced budget samples only near the normal
and reads as uniformly unoccluded. The 4x speedup moved the crevice share
14.7% -> 15.0%, i.e. cost nothing visually. Nothing in the library falls below
0.40 occlusion — the floor clamp is what keeps low-poly art out of the mud.

Contact AO needed one fix found by rendering it: an ellipse inscribed in a
square footprint pulls away from the corners, so a castle piece read as
standing in a spotlight rather than touching the ground. It is a squircle now
(|x|^4+|z|^4=1) with segments landing on the corners and edge midpoints.

STEERING FIXED ONCE, AT THE SOURCE. New libs/game/sim/heading.rs states the
convention in one place — forward is -Z, right is +X, POSITIVE YAW TURNS LEFT —
with heading_to_forward/right, forward_to_heading, steer_to_yaw_rate,
heading_delta. Seven tests read as statements of intent ("steering right
decreases heading") so a future sign flip fails loudly. The car's torque and
its autodrive route-follower both route through it and the inline atan2 calls
are gone. The inversion was exactly the trap the module now documents: positive
steer produced positive yaw, which turns left.

DOUBLE BRAINS, found by wiring: spawn_blocks ran unconditionally after
build_world, so every villager got a SECOND Npc block — two brains steering one
body — plus a second car. 28 NPCs for 14 entities; now 14.

The car is a real mesh (toy-car-kit/vehicle-truck) found by description and
scaled from its own bounds onto the chassis, box hidden. The rigid body stays
the physics.

BIG WORLD RENDERS: ARCADE_WORLD=big, street demo still default. 596 props, 217
colliders, 64 draw items (per-pack atlas batching working), 611 shadow casters,
14 NPCs, 63 of 64 models loaded, 15 ms to plan. 506,962 TRIANGLES — that will
not fit a Quest, and roads are 382 of 596 placements, so road decoration and
distant woods scatter are the first cuts a governor should make.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:34:14 +02:00
Admin
6623408a57 Bind the library and composition into script — and tell the model it exists
8 new verbs (table now 115): find_model (DISTINCT ids, not ranked duplicates),
find_palette (matched set from one pack), model, kits, cast, road_network,
town, dungeon. game.find was ALREADY TAKEN by entity-by-tag lookup — the
duplicate-name test caught the clash before it shipped, and find_model/
find_palette now match the agent TOOL names, so the model's knowledge
transfers between the tool it calls and the verb it writes.

Verbs run synchronously (search and layout are pure CPU); only GLB load and
draw need a host, so placements queue through the same mechanism as audio and
particles — which also means a scene composes headlessly with no renderer
attached. Tiles carry their own collider from the kit pitch, so scripted props
are as solid as hand-placed ones.

THE MOST IMPORTANT EDIT WAS A DELETION. splashgame.md said "Everything is
procedural... No image, model, or audio files" — the doc was actively telling
the model it had no models, which is why generated games were bare primitives
while 4,442 models sat unused. Replaced with an instruction to reach for the
library before game.box, three rules (never place result #1 five times; one art
pack per region; generate layouts rather than hand-placing) and a wrong-vs-
right example. A test asserts that claim cannot come back.

Two bugs found by probing the REAL library rather than reasoning:
- town() would have placed ZERO buildings, silently: it selects
  TileRole::Building, but every role-less model mapped to Prop — and
  city-kit-suburban is 40 whole buildings with no parsed roles. A role-less
  model is genuinely ambiguous (a building on a lot, or a cone at a kerb), so
  kit_from_index now takes a KitUse hint. Against the real library: 104
  buildings, 136 road tiles, 0 adjacency errors
- the index folds crossroads and T-junctions into one `junction` role, but a
  4-way cell needs four open edges; a T standing in for a crossroad leaves a
  road stub pointing at nothing. Disambiguated by name

village.splash is the scenery counterpart to racing.splash: a town, a wood of
four different conifers, a dungeon, a playable character — and not one model id
written by hand.

NOT BOUND, and why: game.tree/rock/blob and game.scatter generate MESHES, and
set_models takes an asset id, not geometry — there is no mesh-upload path for
generated meshes yet, so binding them would have meant faking it. Additive once
a generated-mesh queue exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:24:10 +02:00
Admin
8dadda7f4b Movers push each other apart instead of clipping through
A POST-PASS, not a sweep change. The sweep carries the 0.55 step-up,
CONTACT_SKIN and the terrain-cliff logic, and every existing contract was
written against it, so it is untouched. separate_movers runs after the whole
integration loop — which also makes the result independent of who stepped
first — and before rider pinning, which stays authoritative.

HORIZONTAL ONLY, resolving the least-penetration axis of x/z. Resolving
vertically is exactly how characters end up standing on each other's heads; an
overlapping pair is pushed apart on the ground plane and a stack unpicks
itself.

Three FIXED relaxation passes, deliberately not convergence-based: an
early-exit on "nothing moved" makes the result depend on iteration order, and
this has to be bit-reproducible. Broad phase is a uniform grid sized 2x the
widest half, with buckets as a sorted (cell_key, index) array rather than a
hash map — allocation-light AND ordered without a second sort. That replaced a
hash map of per-cell Vecs and took allocations from 617/tick to ~15.

Each shove is clamped by sweep_axis against the solid world. Without that, a
crowd pressed against a wall squeezes its outermost members straight through.

push_mass weights the split by the OTHER body's mass, so equals each give half
and a player at 4.0 shoulders through NPCs at 1.0. 0.0 — the Default — READS
AS 1.0, not as weightless: a literal zero would make every default-constructed
mover infinitely shovable and divide by zero when two met. Same discipline as
`hidden` over `visible`.

Projectiles are excluded, and that is CORRECTNESS not taste: collect_touches
reports a strike from the overlap itself, so separating projectiles would mean
a bullet could never touch anyone. Sensors, collide:false decor and attached
riders are skipped too.

  50 packed movers               0.023 ms/tick
  200 packed movers              0.123
  12 villagers + 500 static      0.107
  200 movers + 500 static        2.020
Packed crowds where everyone overlaps a neighbour — the honest worst case. The
200-among-500-statics figure is dominated by the per-shove static clamp; at the
realistic 12-50 NPCs it is 0.1-0.25 ms. The fix if 200+ becomes normal is
accumulating pushes and clamping once per mover per pass, deliberately not done
because it changes Gauss-Seidel to Jacobi and the numbers don't justify it.

THE GOLDEN HASHES DID NOT CHANGE, and that is genuine rather than lucky:
mover_scene's walkers start 1.7 apart with 0.4 halves and diverge, and its only
other mover is an attached rider, so no pair ever overlaps and the pass is
inert. Nine new tests prove separation works; the unchanged goldens prove it
does nothing where movers never meet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:20:26 +02:00
Admin
49b0d7f0d4 The village has actual townsfolk: 9 character kinds across 2 rigs
Eight Kenney civilians (character-female-a..f, character-male-a..b) plus the
KayKit knight as a standout — nine kinds across eleven villagers. Picked
through AssetIndex::casts() by taking the rig with the MOST members rather
than by naming a joint count, so a library that later grows a better-populated
rig gets used without editing this. Townsfolk are civilians because a village
wants people, not nine fantasy heroes; the knight stays because he is the
figure the player already knows, and having him on the other rig is what makes
the multi-rig path real rather than theoretical.

CLIPS RESOLVED BY NAME, PER MODEL. The rigs name locomotion differently —
Kenney's 7-joint civilians use idle/walk/sprint, KayKit's 41-joint heroes use
Idle/Walking_A/Running_A. clip_index is case-insensitive, so one ordered
fallback list covers both. Borrowing an index across rigs would have animated
a spellcast or a death pose.

TEXTURE BINDING was the real bug this exposed: SkinnedBatch carried ONE texture
for all items, which silently renders one character in another's atlas. It now
carries a texture palette with a per-item index, clamped rather than indexed
blindly so a bad slot cannot panic mid-frame. (KayKit embeds its atlas and
ships a sidecar; Kenney characters reference a pack-shared colormap — both
arrive as bytes, so the distinction disappears at load, but the BINDING had to
become per-item.)

Cost went DOWN: 17,440 verts skinned per frame, 408 KB/frame, against 958 KB
for the eleven-knight village, because a civilian is ~1,300 verts to the
knight's 3,716. The shape of the cost is unchanged and still doesn't scale —
GPU skinning remains the right fix.

Found by looking, not by testing: height normalisation was INVERTED, and the
first capture showed villagers about half the height of their own front doors
(Kenney's "mini" characters are ~1 unit against the knight's ~1.8). That
normalisation is keyed off joint count, which is crude — a third rig would want
measured rest-pose bounds, and SkinnedModel exposes none today.

63 props, 58 colliders, 11 NPCs of 9 kinds, 19 draw items, 12,244 triangles,
88 shadow casters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:07:19 +02:00
Admin
0ac21bdc9d A real cast: 36 rigged characters across 3 shared rigs — and Kenney IS rigged
THE FINDING THAT CHANGES THE PREMISE: "1 rigged model in 4,442" was measured
with the BROKEN GLB probe (the gLUF magic bug). With the probe fixed the
library holds 36 rigged models across three rigs:
  41 joints — 9 KayKit heroes + undead, up to 95 clips
   7 joints — 22 KENNEY civilians (male/female a-f, orc, human, archer, shop
              employees, skaters, soldiers), 32 clips
   6 joints — 5 Kenney platformer characters, 25 clips
So a village can be populated with 22 visually distinct civilians TODAY, with
no third-party pack at all. Every conclusion drawn from that probe before it
was fixed needs re-checking, not just this one.

KayKit: 9 characters fetched (Adventurers + Skeletons), pinned by commit +
sha256, 37 MB, gitignored. CC0 verified by READING LICENSE.txt at each pinned
commit, recorded in the script header and CREDITS.toml.

THE SHARED RIG HOLDS ACROSS PACKS, proven rather than assumed: hashing the
joint-name list of all nine files yields the SAME digest — 41 joints, same
names, same order — despite two separate repositories. Skeleton clips are a
strict superset (95 = the adventurers' 76 + 19 undead extras: awaken,
resurrect, spawn, taunt). So a clip authored for the knight plays on the
skeleton warrior and one animation path drives the cast. A test pins this,
including that both packs are present, so a version bump cannot silently break
it.

The texture trap that cost the Kenney fetch three attempts does NOT apply:
KayKit GLBs EMBED their atlas (image/png in a bufferView), verified by parsing
all nine.

tests/rigged.rs parses all 36 rigged models through makepad_game_render::skin
— the loader the app actually runs — and asserts the index's joint and clip
counts match it. Deliberate: the index's own probe was wrong for the entire
library once and survived because the fixture encoded the same error.

find_cast groups by JOINT COUNT rather than pack, because the valuable fact is
cross-pack interchangeability. Cast states are the INTERSECTION, not the union
— advertising a state one member cannot perform is worse than a shorter list.
Added the state words the skeletons needed (spawn/resurrect/taunt/use):
Skeletons_Awaken_Floor previously matched nothing, so "an undead that rises
from the ground" was unfindable.

One bug found in its own work: casts_to_json emitted a doubled closing brace —
malformed JSON that still looked fine in a log. Fixed with a structural test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 11:43:42 +02:00
Admin
193d8b21b6 The village is inhabited: 11 NPCs walking, pairing up, and colliding
Eleven villagers as Movers driven by the Npc block — no special-casing, so they
collide with houses, benches and the fence exactly as the player does. Between
tick 120 and 300 they redistribute along the street, TWO PAIR UP AND TRAVEL
TOGETHER (the Follow behaviour), one settles by a bench, and an east-bench
cluster disperses. The rigid crate pyramid topples in the same window, so the
physics demo is still live underneath.

Knight split into a shared rig + per-villager pose: model, atlas and clip
indices load once; Villager holds only pose buffers, walk phase, tint and
build. follow() reads velocity back off the entity AFTER the sweep, so facing
comes from actual travel and the walk cycle advances with distance covered —
a villager stopped against a bench stops its legs instead of moonwalking. The
old hardcoded triangle-wave patrol is gone.

Per-villager tint (one vec4, one multiply in the vertex stage), because one rig
serves the whole village and without it every passer-by is the same knight in
the same colours — the identical-clones failure the prop variety work had just
fixed.

Scene faults fixed: the fence ran along z=17 while the yard sits at z 14..26,
crossing the green and enclosing nothing — now two legs meeting at a corner.
The yard is dressed with stock crates and barrels so it reads as a working yard
rather than a physics harness. The stray teal/orange lozenge is fixed AT THE
SOURCE: "rock stone" used Spread::Mixed, which round-robins across families,
and the neighbouring family is cliff_blockCave_rock — a cave-mouth tile that
reads as a small teal-roofed building. Variants keeps it inside
nature-kit/rock_largeA..F. Camera pulled 56 -> 44 units; a third of the frame
was bare lawn.

63 props, 58 colliders, 11 NPCs, 19 draw items, 12,244 triangles, 88 shadow
casters.

KNOWN COST, left documented at the call site rather than buried: CPU skinning
is ~41k verts and ~958 KB uploaded EVERY FRAME for eleven villagers. Fine here,
wrong for a town or a Quest. The bone palette is already computed, so the GPU
swap is this one loop plus a shader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 11:40:04 +02:00
Admin
5266625cb6 The Knight was never in the world — plus variety, fog, shadows, ranking
THE WALK-THROUGH BUG WAS NOT THE COLLIDERS. Knight::tick wrote a triangle wave
straight into self.pos, and find(|e| e.tag == "knight") returned None — no
mover, no sweep, no Character block. He passed through benches, houses and
trees alike because there was nothing to collide WITH. The collider maths was
right all along, which is exactly why the house-wall and tree-trunk tests
passed while the user kept reporting walk-through three times over.

Dumping the bench's real collider before changing anything confirmed it:
graveyard-kit/bench yields 1.36 x 0.9 x 0.78 at ground level — a perfectly good
obstacle that nothing was ever tested against.

The Knight is now a BodyKind::Mover (hidden, so the mesh stays his appearance).
tick sets a HEADING; desired_velocity feeds entity.vel; his rendered position
is read back AFTER the sweep. Intent goes in, physics decides where he ends up.
Two things that fell out: his half-extents would have been 1.4 m wide and 3.6 m
tall, because spawn takes FULL size; and his patrol line ran straight through
the bench row, which — now that he genuinely collides — would jam him against
the first bench forever, so he walks the pavement between road edge and
furniture. Walking AROUND obstacles is NPC behaviour, not layout.

prop_collision.rs loads the real bench GLB, reproduces compose_village's
scaling, and walks a Knight-sized mover into it. spawn() now routes through
push_entity rather than entities.push, so the sorted-id invariant is asserted
rather than assumed.

VARIETY WIRED: 5 house designs instead of one model five times, 4 distinct
pines, a real lamp post instead of a CACTUS, two real benches instead of a
coaster-train carriage and a park entrance. Two genuine bugs in the variety
layer, both making find()'s correct answer worse:
- dominant_pack SUMMED 60 hits, so mass beat quality: nature-kit's incidental
  "tall" matches out-summed racing-kit's three lightPosts, and a lamp query
  returned a cactus. Only hits within 25% of the top score count now
- Spread::Mixed wanders on multi-word queries — "park bench wooden" let bench,
  coaster-train-wooden and park-entrance each pass on one word

RANKING: whole_query_bonus tested only the ENTIRE query, so "fence" scored the
real fence 28 while "wooden fence" scored it 8 — tied with everything and
decided alphabetically, which is how asking for a fence returned arena/wall.

STATIC SHADOWS 15 -> 69 CASTERS: base_y was the MAXIMUM static top, and the
per-prop colliders are static entities, so the receiver plane sat at roof
height and every prop projected onto a plane above itself. Each prop now uses
its own lowest point — also correct on a slope.

Fog 0.004 -> 0.0015 (24% -> ~10% wash at the treeline), set on the demo rather
than SkyConfig::default() which gamemaker also reads. Fence spacing derived
from the panel's own scaled width. Crate stack is a 3-2-1 pyramid, not a
six-high chimney. Aspect guard so a short wide model can't explode sideways
into a coloured slab — any library picked by description eventually returns
something oddly proportioned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 11:23:53 +02:00
Admin
822d788249 Props collide, cast shadows, and the demo reads as a street
Three complaints from looking at the running app, all one problem: the world
didn't behave like a place.

COLLIDERS COME FROM EACH PROP'S OWN PRIMITIVES, not its AABB. Kenney authors a
house as walls + roof + door frame and a tree as trunk + canopy, so
StaticModel::parts records per-primitive bounds during the existing vertex
bake (they were being merged away). collider_parts() drops boxes under 10% of
the model's span, merges near-coincident ones, caps at 8 — low-res by design.
Policy falls out of the decomposition: buildings/fences/rocks take every
qualifying box; trees keep only parts both narrow and low, so the trunk blocks
and you walk under branches; lamps and decals take none. A prop whose parts all
filter out gets a synthesised box (trees a narrow post), because silently
reverting to walk-through scenery is the bug being fixed — a real catch, since
that fallback first shipped for Solid only and colliders dropped 39 -> 20 when
single-mesh pines found no trunk.

`hidden` rather than `visible`, deliberately: Entity derives Default, so the
field defaulting to false must be the UNUSUAL case. A `visible` flag would make
every default-constructed entity invisible — the same trap as the zero-seed rng
and the zero-gravity bodies this codebase has already been bitten by twice.

Proven by test, not by eye: a walker stops at a house wall but passes through
its DOORWAY (this fails with a single AABB), a trunk blocks while its canopy
doesn't, hidden colliders still block. One test initially "failed" because 120
ticks at 4 u/s travels exactly 8 units — it was measuring the tick budget, not
the collider.

STATIC PROPS NOW CAST. rebuild_static_shadows only walked entities, and props
are ModelInstances whose colliders are hidden, so trees and houses cast
nothing. Placed models feed the same baked layer, caster points sampled from
the model mesh (extremes always kept, then strided to ~48 — a stride alone
misses roof ridges) so a pine's shadow tapers. Cached on (render_rev,
bake_generation, models_rev), merged into one geometry, one draw.

THE SCENE IS COMPOSED: five suburban houses set back from a road all FACING it
(uniform facing is the point — random yaw reads as debris), lamps on one verge,
benches on the other, a fence line, three separated tree stands rather than
uniform sprinkling, and the physics demo gathered into a builder's yard. Props
scale to a target height from their own bounds, since a fixed multiplier gives
a 12-unit bench beside a 2-unit house. Exhaust only emits above 3 u/s (a parked
car under its own smoke column read as a bug).

44 props, 39 colliders, 7 draw items, 15.8k triangles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:50:01 +02:00
Admin
d6ac189391 download_assets: put the texture atlas where the GLB actually looks for it
Third attempt at this, and the first two failed in instructive ways.

A Kenney GLB's material URI is `Textures/colormap.png` RELATIVE TO THE GLB,
and the GLBs land in the pack root. So the atlas has to end up at
`<pack>/Textures/colormap.png` — no more, no less.

- Attempt 1 extracted no PNGs at all ("GLB is self-contained" — false here).
- Attempt 2 flattened every PNG into the pack root, so the file was present
  but at a path nothing resolves; indistinguishable from missing, and it also
  dragged in ~200 MB of Preview/Sample/thumbnail images nothing loads.
- This one keeps the `Textures/` tail and drops everything above it, because
  archives nest it under a per-pack folder (`FBX format/`, etc.) that must not
  survive. Preview/Sample images are filtered out.

The `.extracted` marker added earlier is what makes each attempt verifiable
rather than hopeful: a pack extracted by an older, wrong version is refetched
instead of being reported cached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:30:47 +02:00
Admin
4a70174a06 download_assets: actually extract the texture atlases (48 of 52 packs had none)
Two bugs, and the second hid the first.

The extraction moved only .glb/.gltf/.bin on the reasoning "GLB is
self-contained" — false for Kenney, whose materials reference an external
Textures/colormap.png shared across the pack. PNGs are extracted now. They are
tiny: 212 atlases, ~42 KB total.

The resume check then counted MODELS only, so a pack whose atlas had never
been extracted was cheerfully reported "already cached" and never refetched —
which is why a full re-run fixed nothing and 48 of 52 packs rendered
untextured. Caching is now gated on a `.extracted` marker written only after a
complete extraction, so a partial or superseded extraction can never pass as
done.

(The PNG fix existed briefly in a concurrent branch of work and was clobbered
by another edit to the same script; the marker is what makes it verifiable
rather than hopeful.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:20:02 +02:00
Admin
294314fb73 render: Kenney's 4,442 static models actually draw — the demo is a place now
THE BLOCKER WAS UPSTREAM OF THE RENDERER: zero of the 4,442 models had a
texture on disk. download_assets.sh extracted only .glb and deleted the zip,
reasoning "GLB is self-contained" — false for Kenney, where every material
points at an external Textures/colormap.png shared across the pack. PNGs are
extracted alongside now, and the resume check REQUIRES a texture, because a
pack of GLBs with no atlas renders white, which is worse than missing.
(The atlases are tiny: 212 textures, 42 KB.)

Static path (model.rs) reuses skin.rs's container/JSON/accessor code rather
than growing a second parser. A static mesh is a skinned one minus joints,
plus one difference: a prop never animates, so each node's world transform is
BAKED into its vertices at load and the model becomes one buffer. Dropping
that bake is exactly how a prop silently renders at the origin, so there is a
test for it. All 4,442 models parse: 1.31M triangles total, 294 average —
comfortably Quest-sized.

Kenney ships TWO conventions, and the second only turned up by looking at a
failure: most packs UV-map into colormap.png, but nature-kit and friends carry
no texture at all and colour each primitive with a material baseColorFactor.
Rather than branch, that factor is baked into the packed vertex's colour lane
and multiplied in the shader (albedo * v_tint) — atlas models carry white,
untextured models get a white 1x1. One shader, both conventions. A model that
DECLARES an atlas but cannot find it stays a hard error; that case really is
broken.

Batching sorts instances by model so equal geometry+texture land adjacent and
accumulate into one draw item: the demo runs 36 instances in 5 draw items,
9,887 triangles. Copies of a prop are free; cost is per distinct model.

The demo picks props BY DESCRIPTION through the asset index (find("pine
tree")), not by hardcoded paths, so it exercises the same path a generated
game takes — and it walks the ranked hits taking the first that loads, so a
pack with a missing atlas yields to the next candidate instead of leaving a
hole. Pillar ring and cone removed; they read as a test harness.

Honest read of the captures: before, coloured cylinders and spheres on a slab
— unmistakably a tech demo. After, a woodland treeline at mixed scale and
species, a suburban house with windows and a teal roof, wooden fences,
textured crates, correctly lit and shadowed. Still imperfect: "boulder"
resolves to nature-kit/cliff_blockCave_rock, a cave-mouth block that reads as
a small building scattered about — a SEARCH-QUALITY gap for the alias owner,
not a render bug.

Washed-out look diagnosed (not fixed, out of scope): it is FOG, not the bake
or the textures. SkyConfig::default()'s density mixes every surface toward the
pale horizon (0.75,0.87,0.96) over a 34-unit camera distance — the far
treeline desaturates toward sky colour while near crates keep their brown. Fix
is either a lower default density or making fog colour follow the sun's
horizon tint so it reads as haze rather than a grey wash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:16:07 +02:00
Admin
6769def3ea Arcade: expose the app as a library so the eval harness measures the real thing
apps/arcade/src/lib.rs (plus the [lib] section committed alongside it in
3cef1eb29, which referenced a file that wasn't tracked yet — HEAD did not
build without this).

The point is stated in the module doc: tools/arcade_eval must send the same
system prompt and the same tool policy the app sends, or it measures a
fiction. Exporting the modules is what keeps the harness and the app from
drifting; the binary keeps its own mod declarations because app_main! owns
the process entry point.

Carries the game_script changes the harness needs alongside it (input.rs and
the dispatch/host/value edits made while wiring the headless evaluation path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 09:23:14 +02:00
Admin
3cef1eb290 Arcade binary -40%: make TTS optional, and lazy when present
libs/converse gains a default-on `tts` feature so gamemaker and route are
unaffected; Arcade takes default-features = false and re-enables it through
its existing `voice` feature. With tts off, SpeechOutput still exists and its
worker drains the queue silently, so no Arcade source changes were needed —
which also avoided colliding with concurrent edits to those files. No
capability is lost: speech is fully reachable under --features voice.

  Arcade before            25.11 MB
  Arcade after (default)   15.01 MB   (-10.10 MB, -40%)
  Arcade --features voice  26.18 MB
  hello_world              13.46 MB

Isolated by A/B in a throwaway worktree before making the real change. Of the
10.1 MB, 2.78 MB is us_lexicon.bin embedded via include_bytes!; __const alone
was 11.2 MB, larger than __text.

Also: Kokoro construction is now lazy, deferring ~327 MB until first
utterance. That already closed the RSS gap this pass was chasing — Arcade's
max RSS measures 190 MB against hello_world's 222 MB on the same method, i.e.
BELOW the baseline app, so the premised memory problem no longer exists and
was not invented into one. The lean-isolate prelude was likewise dropped after
instrumentation showed Arcade allocates no script isolate on the demo path at
all; it should be re-measured against a loaded script game before anyone
spends effort there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 09:22:03 +02:00
Admin
f35acc03e8 Fix HEAD: skin_to_packed call site + track tools/arcade_eval
Two ways HEAD failed to build for a fresh checkout, both from work landing
across concurrent streams:

- 623ee745e renamed skin_to_pbr -> skin_to_packed, but arcade_view.rs was
  being edited by another stream at the time, so its call site fix stayed
  uncommitted while the rename landed
- Cargo.toml listed tools/arcade_eval as a workspace member while the crate
  itself was untracked, so loading the workspace failed outright

Both verified: the crate builds, and a stash-everything check now leaves a
working tree that compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 09:20:47 +02:00
Admin
5f83480ae3 Arcade assets: full Kenney 3D catalogue (4442 models) + ranking fixes
52 packs, 4442 GLB models, 136 MB on disk — fetched sequentially with resume
(a hash-valid pack is skipped, so an interrupted run costs nothing) via
kenney.nl's content-hashed URLs with per-zip sha256. MIRROR.toml records
every pack's canonical URL, sha256, size and file count, so a mirror is
reproducible; --mirror=/ARCADE_ASSET_MIRROR redirects the base URL and fetch()
verifies the digest identically whatever host served the bytes — a mirror we
control is never trusted more than upstream. --packs= keeps a fresh clone from
being forced to pull everything.

Aliases restructured to survive the scale: per-pack theme rows (55) so every
model in a pack inherits its setting, filename-token parsing with variant-
marker stripping as the workhorse, and ~240 hand-curated query-time synonyms —
the layer whose curation compounds across the whole catalogue. 82-query suite
reports misses instead of being tuned green; the list is down to 2, both
defensible (a floor IS somewhere to stand; a bell IS a metal clang).

Three ranking bugs root-caused, not patched:
- No stemming, so "smashing" never reached the alias "smash" and "glass
  smashing" returned glass PIPES. Added a conservative stemmer probed at
  synonym strength (only ever adds matches), which refuses to mangle
  glass/grass/class and routes "trees" to "tree", not "tre"
- An overreaching alias: `spaceship` sat on four spaceEngine SOUND families.
  An engine hum is not a spaceship. Removed; "spaceship engine" still resolves
- Kind confusion on ties: spacecraft models tied with spaceTrash sounds and
  lost the alphabetical tie-break. Added kind-aware tie-breaking driven by
  query intent — deliberately a TIE-BREAK, not a score bonus, so it cannot
  drag a weak model above a strong sound (laser gun / explosion / coins scores
  verified unchanged)

Repo-policy violation fixed: all three asset .gitignore files were deny-lists
covering only .glb/.png/.jpg, leaving 302 .gltf files from 3d-road-tiles fully
committable. Converted to allow-lists — 4,744 asset files are now unstageable
by accident.

Scale at 4,999 entries: build 120 ms, search ~0.2 ms, 2.1 MB heap, and the
prompt summary still 479 chars — flat as the catalogue grows, which is what
keeps it affordable in every AI turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 09:17:03 +02:00
Admin
ec80213c4f Arcade sim: stop copying the world every tick (−99.93% bytes on terrain scenes)
step_world cloned two things per tick purely to dodge a borrow: the whole
Terrain (heights AND colors) and every static/kinematic Entity at 208 bytes
each. Terrain dominated — a 257^2 field is 1.3 MB/tick, 79 MB/s of memcpy at
60 Hz, on a world containing seven entities.

- Terrain: copy -> borrow, splitting the struct borrow the way the bottom of
  the same function already did
- Statics: 208-byte Entity -> 48-byte Solid. This one MUST stay a copy —
  movers sweep against kinematic poses from BEFORE this tick's integration
  and that ordering is load-bearing — but it only ever needed
  id/kind/pos/half/vel
- owner_pose: skip building the table when nothing is attached (most worlds)

  scene                ms/tick          B/tick
  demo                 0.002 -> 0.003   15,140 -> 4,796      (-68%)
  racing-ish (129)     0.007 -> 0.002   362,316 -> 8,576     (-98%)
  terrain 257          0.019 -> 0.001   1,323,964 -> 896     (-99.93%)
  large (500 static)   0.063 -> 0.056   591,386 -> 82,382    (-86%)
  stress (2000 static) 0.583 -> 0.457   2,353,936 -> 327,812 (-86%)

Result-neutrality proven, not assumed: new mover_golden.rs covers what
rigid_dynamics.rs doesn't reach (terrain cliffs/floors, sweeps, platform
carry, attach pin, projectile lifetimes, auto-face) and its golden hash is
identical before and after — verified by stashing only the source changes
and re-running, not by re-baselining. Also includes a test pinning the
pre-integration snapshot ordering, so a future "obvious" simplification that
reads live positions gets caught.

Leak check: 36,000 ticks (10 simulated minutes) of a busy world with
projectiles spawning and expiring — RSS flat at 3.8 MB, +0.4% drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 08:50:42 +02:00
Admin
623ee745e4 Arcade render: packed vertex formats — instance -27%, vertex -62%
Quest is vertex-bandwidth bound, so this is the measured headline:

  cube instance             176 B -> 128 B  (-27%)
  skinned character vertex   64 B ->  24 B  (-62%, re-uploaded EVERY frame)
  shadow mesh vertex         64 B ->  24 B  (-62%)

The Knight went 238 KB/frame -> 89 KB/frame: CPU skinning re-uploads the
whole buffer each frame, making it the largest recurring saving available.
Instance sizes are read from the compiled shader (RenderStats::
instance_floats), not counted by hand. The instance win was pure
duplication: sun_color/sun_sky/sun_ground/fog_color are identical for every
instance in a batch — 12 floats per cube — and moved to uniforms.
fog_density stayed per-instance because shadows switch it off individually.

Unblocked by adding geom.GameMeshVertex in draw/geometry_gen.rs and making
the existing pack_pair_f16/pack_unorm8x4 public, rather than writing a
second f16 rounding implementation that could drift from the first.

Three constraints found, worth keeping:
- Vertex attributes here are f32-ONLY. Compression means bit-packing into
  f32 lanes; unpack2f16/unpack4u8 are builtins on every backend
- Pod vertex structs need flat f32 fields, not Vec3f — std140 pads a vec3
  to 16 B and the repr(C) size assertion fails at runtime
- In the shader language `let` is immutable and helpers can't be forward-
  referenced, so the octahedral decode uses branchless step(0,v)*2-1: the
  sign() builtin returns 0 at 0, which would collapse the fold on
  axis-aligned normals

Tape BYTE_IDENTICAL; captures verified after each conversion (shadows
unchanged by packing, Knight correct with packed normals/UVs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 08:41:31 +02:00
Admin
969cb2c3d9 Arcade: Kenney CC0 asset library with an AI-queryable index
75 models (5 KenneyNL starter kits, .glb, pinned commits + sha256, 2.8MB)
and 556 sounds (7 packs via kenney.nl content-hashed URLs, each zip
sha256-verified, 13MB). Nothing large enters git: dirs are gitignored, only
CREDITS.toml and .gitignore files are tracked.

The index is the point — an AI cannot use a library it can't name:
- id is kenney/racing/vehicle-truck-yellow, anchored to where the file
  LIVES, not its category, so retuning the category tree never invalidates
  a saved game
- Filename tokens are the floor; the value is two hand-curated alias tables
  (76 model rows, 116 audio FAMILY rows — Kenney's footstep_wood_000..004
  collapse to one family, so 556 files stay maintainable) spanning
  synonyms, kid vocabulary and misspellings (vehical, hosue, motercycle),
  function over identity ("something to hide behind"), colour/size/
  material, and theme, plus ~190 query-time synonym expansions
- AssetKind model/sound/music so a 30-second track can't be returned as a
  hit sound; GLB probe reads skins -> rigged, animations -> animated
- FIND_MODEL tool descriptor (provider-neutral plain data) + compact
  results; library_summary() is 469 chars for 632 entries and provably
  doesn't grow with the catalogue; resolve_or_explain() rejects
  hallucinated ids with near-misses; local_spawn() gives the local
  librarian a best match plus a confidence blending strength with margin

HONEST GAP: every Kenney audio pack is Ogg Vorbis only — no WAV exists
upstream — and this tree has no vorbis decoder. Sounds are indexed and
searchable but NOT playable: entries carry decodable:false, the agent JSON
emits playable:false so a game cannot fire a silent sound, and
--transcode converts via ffmpeg when present. A real decoder is the fix.

Three bugs found by testing, all fixed: sci-fi-sounds.zip ships a directory
with no owner-write bit so that pack alone silently extracted 0 of 73 files;
prepositions matched phrase aliases ("...at the roadworks" hit "something to
shoot at"), so function words must be dropped, not down-weighted; and exact
names lost to incidental aliases (a coin SOUND outranked the coin MODEL).
Miss list left visible at 2/52 rather than tuned away — both are defensible
answers against over-narrow expectations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 08:31:26 +02:00
Admin
3387e06f28 Arcade: chat panel, mic, and the audio backend it never had
Arcade had no chat UI and no cx.audio_output at all, so M7's positional
audio was silent there and there was no way to talk to the AI.

- synth.rs is a port of gamemaker's 24-voice synth (a shared libs/game/audio
  crate was out of this task's scope; deduplicating the two copies is a
  mechanical follow-up). Gamemaker's audio path is untouched, so no tape
  run was needed. One behavioural addition: STEREO — gamemaker's synth
  writes the same sample to every channel, so a positional sound had
  nowhere to go. Voices now carry pan, and centre keeps full volume in both
  channels rather than equal-power, so every existing 2D sound is exactly
  as loud as before
- audio.rs drains the AudioRequest queue and resolves SfxAt against a
  listener built from THIS device's camera — Local tier by construction,
  nothing reaches the wire. A sound past its range queues no voice at all,
  so a busy world doesn't burn its 24 slots on things nobody can hear. The
  demo world clanks when crates land, so the positional path is audible out
  of the box rather than merely implemented
- chat.rs: PortalList with User/Assistant/System bubbles — engine trouble
  gets its own colour because the player didn't say it and the AI didn't
  either. main.rs is now a Splitter: chat + input + status left, game right
- Voice degrades per tier: a `voice` feature gates the mic (local-llm
  implies it — a judge needs a mic to judge), the text box exists in every
  tier. Caught in headless boot: naming ptt_use_escape in script logged an
  [E] every startup when the feature is off, because VoiceWave is a stub
  View there; push-to-talk moved to feature-gated Rust
- authoring.rs submits the agent's edit to the intent log as a transaction
  against the generation the turn started from, then writes the merged head
  back to disk — otherwise the next mtime poll would re-propose the agent's
  stale text as new. Eval errors return via CoeditResponse::EvalError and
  land in chat as System messages naming the last version that worked.
  Tests assert a typed request reaches the log, and that the LOCAL agent
  gets rebased when it loses a race to a remote one — no privileged path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 08:28:54 +02:00
Admin
a529923c13 Arcade render: CPU light bake, silhouette shadows, instance slimming
CPU light baker (bake.rs): per-static AO (5 face samples x 8 Fibonacci
rays), a sun-visibility term, and a trilinear probe lattice for moving
objects — all folded into instance colours the renderer already sends, so
zero extra bandwidth and zero GPU cost. Demo world, release: AO 15us,
sun 34us, probes 61us. The split is deliberate — AO is the expensive half
and is sun-independent, so a day/night cycle only pays the 34us. A ray
starting above the heightfield peak and heading up skips the terrain march
entirely: that early-out took the probe pass from 5.4ms to 61us.

Silhouette shadows (shadow_mesh.rs) replace the flat oriented quad: caster
points -> projection along the sun -> 2D convex hull -> fan triangulation,
which has no self-overlap and therefore cannot double-darken in an alpha
blend (the reason naive projected geometry bands). Draped over terrain
(vertices drop to ground height, long edges subdivide), soft rim from a
penumbra ring that widens with height, statics cached against (world edit,
sun position) — every shadow in a frame is ONE geometry, ONE draw call.
Z-fighting handled structurally: offset along the RECEIVER's normal with a
slope-scaled term (world-up slides the shadow on a slope), depth test on,
depth write off.

Instance stream 176B -> 128B (-27%), measured from the compiled shader:
sun_color/sun_sky/sun_ground/fog_color were 12 floats of identical data on
every cube and moved to uniforms. fog_density stays per-instance because
shadows switch it off individually.

RNG isolation is structural: GameWorld has no bake field and the ray set is
fixed, so there is no RNG here to share with the sim. Tape BYTE_IDENTICAL.
Deleted an unwired SDF-blob path and the dead project_box_shadow call site
rather than leaving two shadow implementations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 08:28:41 +02:00
Admin
bdbc946012 Arcade M6+M7: packaging/sharing with sandboxed installs, and the pretty pass
Committed together: both streams landed in libs/game/script, so splitting
them would produce two commits that don't compile.

M6 — packaging and sharing
- libs/zip_file gains a writer (store + deflate); real `unzip -t` validates
  our archives in an interop test. Packing is deterministic (fixed
  timestamps, sorted entries), so a package can be addressed by its own
  sha256 — which is what makes the registry's digest check mean anything
- libs/game/pkg: .arcade format (game.splash + manifest.toml + assets),
  total manifest parsing (attacker bytes always yield a Manifest or an
  error, never a panic; non-finite numbers refused rather than defaulted),
  registry client that verifies sha256 INSIDE download so tampered bytes
  never reach the extractor
- Hardened extraction: absolute paths, drive letters (C:x is absolute on
  Windows), UNC, backslashes, .., NUL/control chars, symlink members (via
  mode bits), duplicate names (the ambiguity IS the attack), declared-size
  caps checked before decompressing plus a post-decompress check, entry/
  total/archive caps, and a post-join re-check that the resolved parent is
  still inside the destination — which catches a pre-existing symlink the
  name test cannot see. 4000-round mutation fuzz with a canary file beside
  the destination; a 320 MB deflate bomb under 1 MB on the wire is refused
- Capability stripping rebinds fs/run/net to FRESH EMPTY OBJECTS rather
  than shadowing known verbs, so there is no hole the day someone adds one.
  Applied before the game handle is registered. Vacuity guard: an unstripped
  isolate genuinely reads a file, so the sandbox tests can't pass for
  unrelated reasons. Browser-installed games load Trust::Downloaded

M7 — pretty pass
- GameSun adopts draw::SceneSun (axis-converted: SceneSun is map-space
  y-south/z-up, games are y-up). Shaders compute hemisphere ambient +
  direct instead of each hardcoding its own split; defaults collapse the
  new formula to the old constants exactly, so unifying did not restyle
  existing games. write_into is the single write path — "one sun" is
  compiler-enforced
- Projected shadow geometry: the caster's silhouette along the sun, fitted
  in the sun's own (u,v) frame, so it stretches as the sun swings. Nearest
  N casters get projection, the rest blobs; one instance in the existing
  alpha batch, no extra pass. 0.6us for 24 casters
- Two pre-existing shadow bugs found via capture: the pipeline blends
  premultiplied, so unpremultiplied dark RGB ADDED light instead of
  removing it; and shadows were fogged, mixing them toward the bright
  horizon so a distant shadow came out lighter than the ground it darkened
- Particles are structurally isolated from the sim: GameWorld has no
  particle field and step_world has no particle code — the renderer owns
  simulation and its own RNG. particles_never_advance_the_world_rng
  interleaves particle verbs with real rand() draws over 32 rounds and
  asserts both the RNG state and the drawn stream are identical
- game.sfx_at with listener-relative gain/pan and a near-field ease so a
  sound at your feet doesn't flip channels; 2D verbs unchanged
- apps/arcade/BUDGETS.md: measured particle/sim costs, Quest columns marked
  as estimates (the real particle limit is fill rate, not CPU)

Tape probe BYTE_IDENTICAL. Not done: arcade has no audio backend, so
positional sound is implemented and tested but not audible there yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 03:27:09 +02:00
Admin
c042c06eba Arcade M5: multi-Claude co-editing — intent log, semantic rebase, soft leases
libs/game/coedit (zero deps, so merge logic is testable without a socket or
a VM). Not a CRDT: transactions are host-serialized into an append-only
generation history, and a conflict is answered by handing the author the new
base so THEY re-derive their intent.

- Transaction carries the author's whole intended file, not a patch: the
  diff against its declared base is derived host-side, so a stale or
  malformed patch can never be applied — and an AI writes whole files anyway
- diff3 over lines with LCS anchors. Merge::Conflict deliberately carries NO
  merged text: a half-merged game file that still parses is worse than an
  honest rejection. Conflict -> Rebase{new base + per-generation summary of
  what landed underneath}. An edit already present in the tip is refused as
  NoChange rather than appended as an empty generation
- Leases are advisory as designed: a test asserts a submit SUCCEEDS while
  another author holds the lease. They shape who chooses to edit; they never
  gate the log. TTL expiry means a crashed author cannot lock a region
- Wire: coedit is reliable-channel ONLY — a test signs a valid submission,
  sends it by datagram, and asserts it is ignored, so no datagram can
  rewrite the game. Every response is addressed, never broadcast
- Arcade bridge routes in exactly one place; local agent and remote authors
  share the queue and the rules, and a test asserts the local agent gets
  rebased identically when it loses a race. Remote players map to
  AuthorId(player+1) so a client holding player id 0 cannot impersonate the
  host's agent
- 200-round deterministic fuzz: 4 authors submit against deliberately stale
  bases, rebase, resubmit — asserting linear append-only numbering, no
  generation claiming a base from the future, and that replaying accepted
  diffs from generation 0 reproduces the head exactly

Two real bugs found building it: validate used `?` on the base lookup, so an
unknown base returned "no refusal" instead of UnknownBase; and
MAX_COEDIT_SOURCE (512 KiB) exceeded MAX_FRAME_BYTES (256 KiB), so the host
would have accepted a source it could never hand back inside a Rebase,
stranding the next author on an answer that never arrives. Both now 192 KiB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 02:45:31 +02:00
Admin
564c1b52dd Arcade M3 (part 1): MR/VR stage modes, XR input, settings panel, authoring inbox
Stage as presentation, never simulation. Stage{mode, origin, yaw, scale} is
applied as the scene draw list's view_transform uniform — one value per
frame, so sky/terrain/cubes/skinned characters all move together, cached
static slabs never invalidate (they hold stage-independent world transforms)
and per-instance cost is zero. The view matrix cannot carry this: in XR the
platform overwrites camera_view with the runtime's eye matrices every frame
(openxr_opengl.rs:20-25), discarding anything the app wrote.

- MrDiorama: world scaled onto an anchored slab, sky/fog/terrain-horizon
  suppressed (the room IS the environment), shadow-catcher quad so it looks
  planted. VrFullScale: 1:1, environment intact. Flat: unchanged
- stage_invariants.rs proves the design point: two worlds run 120 ticks, one
  switching flat->MR->VR->MR(new anchor)->flat mid-run, hashes bit-identical
  at every switch, with an assert_ne against a fresh world so the equality
  isn't vacuous. The stage has no API by which it could reach the world
- Stereo needed NO work, and rendering twice would have been wrong: XR uses
  single-pass GL_OVR_multiview2, so draw_pass.camera_view compiles to an
  indexed [VIEW_ID] lookup and the GPU rasterizes both eyes from one
  encoding. The existing draw path is already stereo-correct
- XR input maps controllers/pinch onto the same per-player InputState the
  net layer sends — an XR player is just another player to the sim
- Quest APK packages (arm64-v8a, passthrough/handtracking/anchor/colocation
  manifest bits) with no cargo_makepad changes
- Settings panel: provider/model pickers, masked key entry, pair button
  showing LAN URL + confirm code, active-tier status line
- Authoring inbox: keyless clients' Intent::Authoring queued for the host's
  agent, bounded and refusing with a reason — every entry eventually costs a
  paid call and it is filled by peers who hold the lobby key but are not
  trusted terminals

Pre-existing startup crash fixed: run_tick held a RefMut across
self.world.borrow() (`let _ = w` drops the reborrow, not the RefMut it came
from), aborting the demo path. Reproduced at HEAD with changes stashed.

Not wired, needs a device: passthrough is requested via
StageMode::wants_passthrough() but not handed to xr_passthrough (arcade has
no XR root widget yet); env-depth occlusion has a hook and no consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 02:32:22 +02:00
Admin
cec4f5b380 Arcade M4: voice + AI tiers, game library, /pair key flow, and libs/game/script
- libs/game/script (new): the table-driven game.* binding layer game.md
  called for — 71 verbs in a HashMap built once per isolate, replacing
  gamemaker's 84-arm linear chain. spawn_entity/spawn_terrain ported
  verbatim so fixture terrain matches bit-for-bit; generation-tagged
  callback slots; streaming eval + hot reload. Rollback snapshot is
  GameWorld::clone() — M1a made the world Clone, and a clone cannot forget
  a field, which is the exact bug class that put next_id in M0r's fix list.
  (Gamemaker still runs its own copy; migrating it is a follow-up.)
- Capability tiers: Voice (VAD + Whisper + local judge) -> VoiceUnfiltered
  (push-to-talk, every utterance costs a call) -> Chatbox (typing is the
  gate). Text box in every tier, mic only above Chatbox. The chain sits
  behind the local-llm feature so Quest/mobile never link a backend they
  don't have
- Librarian: the local model is an optional override (None = no opinion),
  never a gate — a flaky or absent model can only sharpen a decision.
  Beneath it, deterministic word-overlap matching for load-by-description,
  restart, and manifest-clamped knob writes. Creative requests deliberately
  do NOT match an existing game (tested). Locally-answerable utterances are
  dropped before they reach the cloud
- /pair: self-contained page (no external URLs, asserted), 4-digit confirm
  code so a room of headsets can't take the wrong key, 0600 config-dir
  storage documented as NOT a keystore — Android/iOS must move to the
  platform one before shipping. Key never enters a log, package, or error
- Racing fixture evals through the new dispatch (28 entities) and renders;
  hot-reload rollback verified live — a bad verb reports with a suggestion
  and keeps the last good world

Found (platform, unfixed — out of this task's scope):
HttpServerHeaders::from_tcp_stream buffers past the headers into a
BufReader, so a body arriving in the same TCP segment is swallowed and
handle_post blocks forever on a body that's already gone. Browsers split
the two, which is why nothing noticed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 02:05:50 +02:00
Admin
a8427cda75 Arcade M2b: multiplayer — players in the sim, tiered replication, host/join
A room of devices now plays one game: host simulates, clients send input
and render replicated truth.

- libs/game/sim/player.rs: Players roster on GameWorld, slot 0 is always
  this device. Player 0's input stays in the world's original held/pressed/
  pad/cam_yaw fields and is mirrored into the roster, which is what keeps
  single-player numerics bit-identical (tape gate confirms). Ids are never
  reused, so a stale reference resolves to None, never to somebody else
- The camera-movement knot resolved: world.player_move(p) rotates that
  player's axes by THEIR cam_yaw, carried in their input packet. Player 0's
  branch is the original expression character-for-character (the f32 cos
  widened to f64 kept deliberately — tidying it would move the numbers)
- libs/game/session/replication.rs: Shared = pos/vel/size/kind/tag;
  Derived = facing/anim/scale/glow/blob shadows, recomputed client-side and
  costing zero wire bytes; Local = camera/audio/effects. Statics never enter
  the per-tick stream
- Protocol (additive, version unchanged): EntityDesc + Descriptors message
  splits rare reliable construction data from volatile unreliable state —
  without it a joiner sees poses for entities it cannot build. EntityDesc
  carries pos because statics never appear in the state stream (the
  late-joiner test caught ground arriving at the origin)
- Script: game.players/player_name/player_entity/player_input/bot/on_join/
  on_leave; blocks gained owner: PlayerId so a car reads its own driver
- Arcade: ARCADE_HOST=1 / ARCADE_JOIN=<addr>; clients skip world
  construction and don't simulate
- Racing wire volume, 6 players x 60Hz x 200 entities: 2400 pps,
  20.9 Mbit/s up (audit projected 74 Mbit for the XR stack). Asserted in
  racing_scenario_wire_volume_fits_a_living_room

Two more not-a-playable-default bugs, same class as M1b's rng-at-zero:
Entity::default() leaves gravity_scale 0 (weightless wheels) and ground
without friction gives no traction — only the DSL path filled these in.

255 tests green; tape probe BYTE_IDENTICAL; xr/arcade/gamemaker build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 01:21:44 +02:00
Admin
c13c867547 Arcade M1b: libs/game/blocks — car/character/plane, brains, race kit; racing game in 72 lines
The thesis proof: examples/gamemaker/resources/fixtures/racing.splash is a
complete playable racing game in 72 lines (12-corner oval from a waypoint
loop, 4 cars, gates, standings, restart) with no physics, no AI and no lap
bookkeeping in script. Blocks run engine-side at 60Hz: Blocks::pre_step
(intent -> motion) before step_world, post_step after; Blocks is Clone and
snapshots beside GameWorld so a failed eval rolls both back together.

- game.car (4 suspension raycasts on a box3d rigid chassis), game.character
  (drives the existing mover sweep + owns idle/walk/run blending),
  game.plane; game.drive/autodrive/speed
- Brains: game.wander/chase/patrol/caught — the fixture's hand-rolled AI,
  absorbed engine-side
- Race kit: spawnpoint, checkpoint, place, race, standings, lap/rank/
  finished, score/score_of (Shared-tier data, ready for replication)

raycast_vehicle audit (defects documented in car.rs, still live in xr):
libm sin/cos in steering (unreplicable), a wrong side-impulse denominator
(iaj.dot(iaj) where it should be (I^-1 aJ).aJ), and an unguarded division.
Kept the structure, replaced Bullet's friction solver with an arcade force
model: suspension acts at the contact point, grip and drive through the
centre of mass, steering as yaw torque — no lateral force can generate
roll, so it is stable by construction rather than by roll_influence fudge.

Engine bug fixed at the source: GameWorld::new() left rng at 0 and
xorshift64* is a fixed point at zero, so rand() returned 0 forever for any
world built through the sim API. reset_content seeds on every eval, which
hid it from gamemaker entirely; found by a wander brain that never left home.

Tape probe byte-identical; racing fixture evals clean and drives (AI follow
the line, gates bank in order).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 00:54:22 +02:00
Admin
17263b6666 Arcade M1a: box3d as the dynamics layer (hybrid — movers keep the parity sweep)
- libs/game/sim/dynamics.rs: box3d world inside GameWorld, body mirror
  RECONCILED against the sorted entity list per tick (merge-walk), so
  retain/rollback/reset are correct by construction. Statics + kinematics
  mirrored (kinematics via target transforms so resting rigids inherit
  platform velocity); smooth terrain as a box3d heightfield
- New BodyKind::Rigid + body:"rigid" with density/friction/restitution;
  game.push = mass-scaled impulse; set_pos/set_vel detected via bit-exact
  pose caches (no new dispatch arms). Sphere rigids roll on real spheres
- Entity.orient quat read back per tick; renderer builds quat instance
  transforms for rigids (no shader change). Step order: mover sweep
  verbatim -> reconcile -> world_step(dt, 4) -> readback; rigid-free
  worlds skip the solver
- GameWorld stays Clone via box3d snapshot round-trip (bit-identical
  continuation proven by test)
- Determinism: double-run equality + golden hash 0xa8a2baf71e4a564f
  (aarch64, debug and release). Perf: 0.038 ms/tick for 100 movers +
  50 rigids + 65x65 terrain (budget 2 ms)
- Tape probe BYTE_IDENTICAL; box3d crate untouched; arcade demo gains a
  kicked crate stack (captures verified: settles upright, then topples
  with rotated resting poses)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 00:04:26 +02:00
Admin
ac5ed14285 Arcade M1c: skinned animated characters (glTF skins/clips, KayKit, CPU skinning)
- libs/game/render/skin.rs: GLB container + owned-JSON parser, dense
  accessors, multi-primitive skinned meshes, skin + inverse binds, full
  node-hierarchy palette, T/R/S clips (linear/step), nlerp blending.
  Hermetic tests via an in-code 2-joint GLB; real-asset test skips with a
  hint when the download hasn't run
- DrawGameSkinned shader (PbrVertex + albedo texture, terrain-style
  lighting); skinned batch draws between opaque and alpha passes.
  CPU skinning for now (sub-ms at 3716 verts): the uniform_buffer GPU path
  has zero in-tree runtime consumers — SkinnedModel::palette is the seam
  for the GPU swap
- apps/arcade: download_assets.sh (KayKit pinned commit + sha256,
  idempotent, dir gitignored, CC0 license note in-repo); Knight patrols
  the demo world with idle/walk blending, yaw follows path; captures
  verified (pose advances, depth-occludes correctly)
- renderer binary_search sites use the shared sorted-id helper from M0r

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:35:09 +02:00
Admin
7bb0c3be34 Arcade M0 stage B: rendering extracted to libs/game/render + arcade live viewport
- libs/game/render: the 5 game draw shaders (script_mod registration),
  shape geometry + winding test, terrain mesh, static-slab instancing,
  draw_scene pass (sky/terrain/opaque/alpha) as GameRenderer over GameDraws
  (draw structs stay #[live] on the host widget so script theming works),
  CameraRig + scene_state per-view (multi-view ready), HUD + billboard
  label drawing. game_view.rs 4640 -> 3599 lines
- apps/arcade: first engine-only viewport (arcade_view.rs) — GameWorld
  built via the sim API with no script VM, 60Hz tick, orbit camera,
  offscreen pass composite; ARCADE_CAPTURE=<png> GPU-capture test hook
- Verified: sandbox3d fixture evals clean headless; arcade demo frame
  GPU-captured and visually checked; suites green. Sim untouched — stage-A
  tape byte-parity (probe.txt identical vs pre-refactor binary) stands

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 22:56:22 +02:00
Admin
f2228a4654 Arcade M0 stage A: gamemaker sim extracted to libs/game/sim + deterministic math + apps/arcade shell
- libs/game/math: cross-arch bit-deterministic f32 kernel (f64-internal
  fdlibm-style polynomial kernels, exactly-rounded IEEE ops only, zero
  platform-libm transcendentals). Parity golden hashes recorded on aarch64;
  equal hashes on every other target IS the determinism test
- libs/game/sim: the gamemaker world moved verbatim (entity/terrain/world/
  queries/step — float expression order preserved so tapes replay
  bit-identically). No Cx, no draw types, no ScriptObjectRef: script
  callbacks are CallbackSlots resolved through a generation-tagged
  host-side CallbackTable in game_view.rs; sim reset no longer touches audio
- examples/gamemaker: game_view.rs 5545 -> 4640 lines (script boundary,
  rendering, input devices, host I/O remain); per-tick args/input objects
  built via unchecked pushes + release_transient (flat-heap tick path)
- apps/arcade: Makepad Arcade app shell + workspace membership
- Verified: headless sandbox3d fixture (90 entities) boots end-to-end;
  gamemaker/sim/math/script-test suites green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 22:28:06 +02:00
Admin
f4af4db88d Map: growing-archive watcher — the app reloads as the world spiral weaves cells in
5s mtime watch on the active archive (root.mkidx for mkmap); on change,
Failed placeholders clear and the visible loop re-requests. Workers
already reopen per batch, so an atomic shard-set swap appears live —
apps/route now points at world.mkmap and starts empty until cell-001
(NL) lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:44 +02:00
Admin
c073c08596 Revert to known-good: pass batching opt-in (MAKEPAD_BATCH_PASSES=1), AI loading opt-in (MAKEPAD_AI=1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
c59fe0b0eb Metal: frame-batched command buffer for offscreen passes + big benchmark window
Texture-mode passes append to one retained command buffer flushed at the
window pass (safety flush at repaint start for texture-only frames) —
the 12-pass gauss pyramid paid ~1ms commit/schedule latency PER PASS.
Profiling mode (MAKEPAD_GPU_PROFILE) keeps per-pass buffers so spans
stay attributable. Startup window 3400x2050 for pixel-heavy iteration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
768fc340a7 route: NL-first iteration — app reads nl-kf-test 4-keyframe cut (15-18 + shadows)
Europe re-cut with these buckets happens on explicit go; the NL cut
lives in local/maps (untracked) for cross-session stability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
627f0bb04b Map: app reads .mkmap shards — MkmapReader + TileArchiveReader, varint-KV index v2
libs/mbtile_reader gains the .mkmap consumer: root.mkidx parse (Hilbert
tile ids, root ranges -> brotli leaf directories -> shard/offset/len),
positioned shard reads, same get_metadata/get_tile_decoded surface as
MbtilesReader; TileArchiveReader sniffs the path so mbtiles_path can
point at either. Index format v2 drops JSON: the metadata section is now
varint KV like the leaves (writer + reader; no serde in the container).
Loader, zoom-range probe and the headless harness go through the enum;
bridge-dz/overlay sidecars stay mbtiles. Harness parity via shards:
worst AMS rz16 164.7ms, z12 native 51.3ms — identical to mbtiles. App
repointed to local/maps/europe-base-br.mkmap (111 shards + root.mkidx,
the exact bytes a CDN would serve).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:42 +02:00