An indented code block in module docs is a doctest, and this one contained
a bare `...`, so `cargo test -p makepad-platform` has been failing on a
snippet that was only ever meant to be read. Fenced as `ignore`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
THE LEAD I GAVE WAS WRONG, AND THE REAL BUG WAS BIGGER. DrawGameSkinned was
never mis-set — `git show HEAD:...shaders.rs` confirms it has always culled.
The defect was DrawGameCube: it inherits `..mod.draw.DrawCube`, which never
declares culling, so it silently took the PLATFORM-WIDE DEFAULT OF FALSE
(platform/src/draw_shader.rs:41). That is the shader drawing every slab, crate,
ground plane, primitive and rigid body — most of the screen — and all of it was
rasterising its hidden back faces. The trap is that the default is OFF, so not
mentioning culling means two-sided.
Verified safe BEFORE enabling rather than after: geometry.rs already asserted
outward winding against an interior point for every shape, and the measured win
over the real geometry is EXACTLY 50.0% of cube-path triangles back-facing, per
shape, averaged over 2000 view directions. The precision of that number is
itself the winding proof — one flipped triangle anywhere would have skewed it
off 50. A Quest pays this twice, once per eye.
Three shaders stay two-sided ON PURPOSE and now record why at the declaration,
which matters because DrawGameAlpha inherits `true` from DrawGameCube now, so
its `false` became load-bearing rather than incidental: the sky is a cube the
camera sits INSIDE, so every visible face is a back face and culling erases it
entirely; foliage is two-sided cards; the alpha batch carries flat blob shadows
and water where culling changes the composite. A test reads the shader source
and asserts all six choices, so a future audit that flips one must change the
stated intent too.
THERMOMETER (thermometer.rs): p90 over a 120-frame window, never a mean — that
is what makes "hiccups ignored" true rather than aspirational, and two tests pin
it (a single hiccup and scattered hiccups both never cut). Budget from refresh
at 80% (13.9 ms on a 72 Hz Quest, 8.3 ms at 120 Hz). Cuts after 2 bad
evaluations, restores only after 30 good ones with real headroom: degrade
quickly, recover reluctantly, never flap.
The safety property is enforced BY THE TYPE, not by care: Quality's six fields
cannot remove a collider, NPC, player, interactable or HUD element. Cutting is
structurally incapable of changing what the game IS — which is also what lets a
Quest run three levels leaner than the PC beside it while both stay in lockstep.
Opt-in: dormant until a host calls report_frame_ms, and a test asserts level 0
is a bit-for-bit no-op, so linking it cannot change how the game looks on a
machine that never had a problem.
One trap documented at the API: do NOT feed it a vsync-locked frame interval.
That signal is quantised to the refresh rate — it reads ~16.6 ms whether the
frame took 3 ms or 16 ms of real work — and would make a governor targeting 80%
cut forever without ever seeing improvement. Better uncalled than fed a
quantised number.
Three Quality dials (decor_distance_scale, foliage_scale, draw_distance_scale)
are inert until the world-build side can say which props are decoration and
which are structure. Exposed via quality() for whoever picks that up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A game gets walking, jumping, a follow camera, mounting and dismounting without
ever touching a camera itself:
blocks.player_rigs.insert(PlayerId::LOCAL, PlayerRig::new(character_id));
blocks.tick_player_rigs(&mut world, &raw_inputs);
blocks.pre_step(&mut world);
rig.apply_camera(&mut world);
PlayerRig::tick fills BOTH the walking and driving fields of DriveInput every
tick, so changing seat needs no second code path — whichever block is listening
reads its own fields.
The character craft already existed in character.rs and passed; what was missing
was anything consuming it. The camera side is CameraConfig::on_foot (rotates
faster than it translates, so position lag reads as weight while aim stays
crisp; no recentring — the player aims it and it stays) and ::in_vehicle (lower,
35% speed pullback, recentring after a 0.9s delay so it yields to your hand and
only takes over once you let go). Mounting blends with smoothstep.
FOUR REAL BUGS, three of which no existing test could have caught:
- The camera blend never interpolated: `blend / blend.max(0.0001)` is always
1.0, so the rig held its old shape for the whole transition and snapped at the
end — precisely the cut the blend exists to prevent. It needed the blend's
ORIGINAL length, not the remainder
- The blend timer froze when its subject vanished, because tick bailed on the
entity lookup before advancing time. A car despawning mid-blend would have
frozen the camera permanently
- **heading_to_right returned LEFT** — the exact negation of forward x up. Its
doc said "+X" and its test asserted `r.x < -0.99 || r.x > 0.99`, which accepts
BOTH SIGNS and so could never fail. A test that cannot fail is worse than no
test, because it is counted as coverage
- The renderer and the sim use opposite yaw AND pitch signs. Derived by matching
the two eye-position expressions component-wise rather than guessing; the
conversion now lives in heading.rs as heading_to_camera_yaw/pitch — ONE named
boundary, never a negation at a call site. That discipline is why heading.rs
exists, and this is the same bug class that produced the reversed steering
Two changes from peer review: DriveInput.run is f32 so stick deflection gives a
real walk-to-run continuum instead of snapping at a threshold; and pre_step
gained a modality gate, because a player owning both a character and a car was
driving AND walking simultaneously — the stick steering your car was also
walking the body you left in the seat, invisible until you got out somewhere you
had never been.
108 tests across sim and blocks (from 88), including the full walk-in-drive-out
journey, analog deflection landing within 10% of the true midpoint, a reload
keeping you seated with the camera where it was, a vanished car putting you back
on your feet with working controls, and the affordance prompt agreeing with the
button at every distance on the approach.
Known gaps, reported not hidden: dismount picks a side but doesn't check the
GROUND there (spot_clear tests overlap, not floor), recentring uses velocity
heading so slow reversing can hunt, and PlayerRig assumes one local player
because world.cam_yaw is a single device field — split-screen needs a per-player
write path that doesn't exist yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Character feel went into character.rs rather than a fork — its own doc comment
argues a second controller would drift, and it was right. Acceleration and
deceleration ramps, deliberately ASYMMETRIC (starts have weight, stops are
crisp), partial air control, coyote time, jump buffering, variable jump height,
asymmetric gravity via the sim's own gravity_scale, landing damp.
New controller.rs: FollowCamera with separate position and rotation smoothing,
clamped pitch, a boom that snaps IN but eases OUT, look-ahead, speed pullback,
and delayed recentring that yields to the player's hand. Mount/Seat with camera
blending. movement_intent with deadzone and diagonal normalisation, so
diagonal WASD can't outrun cardinal.
ELEVEN FEEL TESTS, each named for the complaint it prevents: speed ramps
monotonically rather than stepping; stopping is crisper than starting; a late
jump off a ledge still registers; a jump pressed before touchdown fires on
landing; releasing early measurably lowers the apex; falling takes fewer ticks
than rising; air control is neither zero nor total; the camera cannot invert or
bury itself; dismounting puts you BESIDE the car, not inside it; the boom
recovers gradually rather than popping.
Two bugs it found in its own work, both invisible to endpoint-only tests:
- Variable-height jump broke an existing test: callers who set jump_pressed but
never hold `jump` — the older single-flag convention, and what a generated
game will most likely write — had their jump cut on the next tick. Now cutting
requires evidence the button is genuinely held; full height for everyone else
- THE CAMERA BLEND NEVER ADVANCED. `blend / blend.max(eps)` is always 1.0, so t
was pinned at 0: the rig held its old shape and then snapped — precisely the
cut the blend exists to prevent. Tracked against blend_total with smoothstep
Four of its own tests were wrong before the code was: air control limits the
RATE, so enough air time still reaches full speed (the real claim is that the
same input builds speed slower airborne); the buffer window genuinely cannot
survive a long fall; two needed the character settled on the ground first.
NOT DONE, deliberately: the controller is not yet exposed as verbs
(game.player_character({})) and arcade still uses its own camera and WASD path.
The prefab and its defaults exist and are tested; binding is the remaining step,
and stopping beat half-wiring input routing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
INTERIORS ARE POCKETS, NOT IN-PLACE ROOMS — a generated room lives at its own
origin elsewhere in the same GameWorld, and a door is a portal to it.
Rejected building the room under the house shell for three reasons. The roof is
the blocker: a third-person camera outside sees the shell, so a character who
walks in vanishes under it, and fixing that needs roof cutaway or per-object
culling — a real renderer feature, not a detail. Kenney footprints are only a
few units across, so an in-place room is whatever the walls leave over, while a
pocket can be bigger inside than out. And a pocket introduces NO NEW CONCEPTS:
it is coordinates in the same world, so host-authoritative replication,
determinism and eval rollback are unchanged, and two players in two different
houses are just two players standing far apart. That last was a hard
requirement and it falls out for free.
DOOR ALIGNMENT is a parameter, deliberately: libs/game/gen must not depend on
libs/game/render, or layout generation would require a GPU. door_side_from_
colliders() takes the boxes as plain data, walks each edge just inside the
footprint, and picks the side with the longest run no box covers — that is the
doorway. Inside the generator the door cell is FORCED via a role-filtered fit,
because a door and a wall segment carry the same connection mask, so an
unrestricted fit sprinkles doors randomly along a room's wall ring. Kit::fit
now delegates to fit_where(target, allow, rng) so rotation arithmetic stays in
one place with one set of tests on it.
Two NPC defects surfaced by testing indoors, both real:
- A door that LEADS somewhere scored the same as a decorative one, and since
the wander fallback sits near 0.5, a lone doorway only tempted homebody
personalities — 4 of 24 seeds. Doors with `leads_to` now score 2.2x: 9 of 24
for a single door in an empty field, and a real village has one per house
- FOLLOW'S SCORE PEAKS AT DISTANCE ZERO while its steering parks at 2.2 units,
so an NPC already standing beside someone picks "go stand beside them" and
then does nothing for up to eight seconds. Outdoors that is invisible. In a
room, where everyone is permanently within 2.2, FOUR NPCS FROZE SOLID for the
entire run. Follow is now only considered when the target is worth walking to.
This would have shipped as "NPCs stand still indoors"
Blocks never reposition an entity: Npc::tick emits DoorUse{entity, poi, to,
entering} and the host performs the write — the same queue-and-drain shape the
audio emitter uses. Coming back out is unconditional, so an NPC can never be
lost behind a door; while inside, decide() short-circuits to a local wander,
because every POI, friend and home is outside and scoring them would aim the
NPC at an interior wall.
Release cost per interior: 4x4 room 20 us / 41 tiles, 12x10 407 us / 186 tiles.
Twenty houses is well under a millisecond. Always exactly two layers — shell
and furniture — so one kit is one batch, as everywhere else in levelgen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
blocks/npc.rs + sim/sense.rs. Utility scoring re-run every ~0.45s per NPC,
staggered so a crowd doesn't re-plan on one tick. Candidates scored additively:
visit a POI (tag appeal x distance falloff x novelty x jitter), loiter near
someone (gated on sociability), go home (grows with time away), wander (the
fallback, so a world with no POIs still moves).
Three things do the legibility work. POIs, so NPCs walk to THINGS rather than
coordinates. Per-NPC seeded personality (haste/patience/sociability/curiosity/
homebody) so identical config still yields unlike villagers. And a day clock
with a per-NPC phase offset — benches read as afternoon, doors as evening —
which is what stops ten villagers doing the same thing in unison. Activities
are deliberately only four (Idle/Travel/Dwell/Follow); routines come from
sequencing them, not from twenty verbs.
Sensing reads THE SAME SOLID FILTER the mover sweep uses, so perception and
collision cannot disagree. obstacle_ahead sweeps the NPC's own box rather than
casting a ray, because a ray through a doorway reports "clear" for a body twice
its width. Blocked -> jump if the top is in reach with landing room, else
sidestep toward the side with clearance (blended with the goal so it curves
rather than turning 90 degrees), else a stuck timer abandons the goal.
Reading the existing tests caught a bug in the new logic: "low obstacle -> walk
over it" is wrong, because the 0.55 step-up is a TERRAIN contract and
sweep_axis blocks against static boxes at any height. That branch is gone —
it was exactly the perception/physics disagreement this module exists to avoid.
Two bugs the tests caught:
- MUTUAL SOCIAL LOCK: two sociable NPCs each chose to loiter near the other,
permanently. One moved exactly 0.0 units in 90 seconds. A social cooldown
stops Follow being re-picked immediately
- VILLAGE DRIFT: an unbiased random walk has no centre, and a trace showed a
villager 43 units out with every POI inside 18. Wander steps past a 26-unit
leash now aim home
Cost against a 16.6 ms budget: 50 NPCs 0.007 ms/tick, 200 NPCs 0.046 ms/tick
(full sim step — a pre_step-only figure would be a lie, since without
step_world the NPCs never move and re-decide more often).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
THE RANKING WAS NEVER WRONG. "suburban house building" already returned 21
distinct houses at equal score and "pine tree" six distinct pines. The API had
no way to say "give me five DIFFERENT ones", so callers took hit #1 and placed
it five times — with 4,753 models installed, a scene used about six.
find_many(query, VarietyParams{count, spread, seed, filters}) never returns the
same model twice. Spread::Mixed spreads across variant families before
repeating a shape, which handles both real cases with one rule: five houses
come back as building-type-p/r/s/t/u, eight trees as pine/oak/palm/fat/cone/
detailed. palette(query, seed) returns a matched set from ONE pack.
Three things only visible by looking at output, not by reasoning:
- VARIETY MUST STAY ON-TOPIC. Round-robin across families returned one house
then two driveways and two fences (city-kit-suburban themes all of them
"house"). A relevance band was the obvious fix and was WRONG: an exact
one-word hit ("tree") outscores a compound sibling ("tree_blocks") merely for
being shorter, so banding cut real variety while keeping the drift. What
separates them is whether the family NAMES the thing asked for — applied only
when it leaves something, since functional queries name no shared noun
- VARIETY MUST NOT BECOME INCOHERENCE. Maximal spread gave five houses from
five packs — the junk-drawer failure reached from the opposite direction. The
dominant pack is exhausted before crossing; a test asserts a street uses
exactly one pack
- RE-SKINS AREN'T KINDS. tree_blocks/_dark/_fall is one tree in three palettes;
counting them as three kinds returned the same silhouette six times. Colour
and season tokens are stripped from the family key
Palette grouping needed a coarser key of its own: family_of produced 167 groups
of one id each — a listing, not a palette. Bucketing on tile role or first
meaningful token gives 23 usable groups.
Selection is seeded, so multiplayer replicates a scene as (query, seed) and a
re-run looks identical.
Also fixed: "boulder" returned tower-defense-kit/weapon-ammo-boulder — catapult
ammunition — because that filename says the word while landscape rocks reached
it only via a synonym. A confidently wrong top hit matters more than a miss
here, because a composer places it several times.
The perf test now takes MIN-of-N instead of an average: it shares a machine
with 23 other tests, and the same query measured 2.1 ms alone and 44 ms under
the full parallel suite — a 20x swing with no code change. The fastest run is
the one that actually got the CPU. Same protocol the box3d benchmarks use.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
THE BUG: GLB_MAGIC was 0x4655_4C67, which spells "gLUF" — not "glTF"
(0x4654_6C67). The magic check therefore rejected EVERY REAL GLB, probe()
returned defaults, and the entire 4,442-model library indexed with
rigged:false, animated:false, size:None. Size filters silently matched
nothing; no model was ever detected as rigged. Any claim made from that
metadata — including "Kenney has essentially no rigging" — was measuring a
no-op, not the catalogue.
It stayed invisible because THE TEST FIXTURE WROTE THE SAME WRONG MAGIC, so
the test and the bug agreed with each other. Fixing the constant broke that
test, which is exactly how a fixture should behave once it stops encoding the
defect. A second bug sat behind it: bounds() searched for "max" only AFTER
"min", but Kenney's exporter writes max first, so bounds would have failed
even with the magic fixed. Both fixed, both with regression tests.
Consequence: the previously-reported 120 ms index build was timing a no-op.
Real probing is ~1.8 s for 5,309 models, now cut to the declared JSON chunk
and parallelised across <=8 threads (std-only, order preserved,
deterministic). The proper fix is caching probes by path+mtime — NOT done, and
the perf bound is now 12 s with a comment saying why rather than a tight
number the test cannot control under contention.
KIT INVENTORY — 23 kits, 2,064 tiles, grouped so a query returns a coherent
visually-matching set instead of one tile from each of five kits. Tile size is
the MEDIAN horizontal extent (kits ship occasional double-width pieces, and a
mean lands between grid pitches — a value no tile uses). Highlights:
city-kit-roads 72 tiles @1.00, coaster-kit 183 @4.00, tower-defense-kit 160
@1.00, marble-kit 162 @1.20, platformer-kit 153 @1.00, modular-buildings 108
@1.00, racing-kit 112 @1.05. The most useful single fact: modular-dungeon,
-cave and -space kits have IDENTICAL role histograms — one layout algorithm
drives all three and the kit choice is pure theming.
Honest failure: city-kit-commercial (41) and city-kit-industrial (25) yield
ZERO roles — their files are building-a..building-z, whole buildings with no
role vocabulary. Grid-placeable but not composable; arguably not kits.
Adjacency ships as DATA (ROLE_ADJACENCY) for the composition layer and is
deliberately coarse: Kenney filenames say what a piece IS, never which edges
are open, so anything finer would be invented. Also added: role/kit/clips/
joints on entries, kits()/kit_tiles() grouping, a find_kit agent tool (<2 KB
so the AI can discover a coherent set before composing), and composition-intent
vocabulary.
Inert per the Kenney-only scope cut: clip extraction, Quaternius source
support, .gltf support — tested and harmless. 64 fetched Quaternius models
were deleted after verifying they parse (46 joints/13 clips).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Procedural LAYOUT + authored TILES: the AI decides where things go, Kenney's
artwork decides how it looks. Beats both random prop scatter and purely
procedural geometry, and it is how low-poly games are actually made.
ADJACENCY HOLDS BY CONSTRUCTION, not by rules. Rather than pairwise rules
between named roles (fragile and quadratic), layout marks occupied cells, each
cell reads its target mask off its occupied NEIGHBOURS, and a tile is chosen
matching that mask at some rotation. Both sides of every shared edge derive
from the same grid, so only rotation arithmetic can be wrong — and that is
what the tests pin. Junction type is never specified by a caller: two crossing
paths yield a crossroad, one teeing in yields a T, purely from neighbour count.
The interface deliberately keys on a 4-bit N/E/S/W `mask`, not on `role`, so
these algorithms don't depend on the asset index's filename taxonomy — if a
kit classifies `road-split` oddly, setting the mask keeps everything working.
Incomplete kits fall back to a superset tile: a crossroad standing in for a
missing tee leaves a stub opening onto nothing, which reads as unfinished road
rather than a hole in the world.
Generators: road_network (polylines), road_from_spline (the authored-tile
counterpart to the existing ribbon mesh — a kart track wants the ribbon, a city
street wants tiles), town (street grid, buildings on lots that front and face
a street, props at junctions), dungeon (BSP rooms + corridors, connectivity
guaranteed by the spanning tree and PROVED by flood fill over 12 seeds), plus
place_tile as the escape hatch.
track from closed spline 13 us 120 tiles
road network (13 paths) 32 us 397
town 24x24 71 us 547
town 60x60 822 us 2710
dungeon 48x48 137 us 1180
dungeon 96x96 932 us 3616
Town road histogram: 1248 straight, 121 cross, 44 tee, 4 corner, 0 dead ends —
correct for a closed grid. Zero mismatched edges on both large levels.
Two bugs caught by its own tests: indexing one kit with another kit's
placement indices (now impossible — layers merge by kit id, invariant
documented), and a superset-fallback that allocated a Vec per cell and tripled
generation time. The allocation-free count-then-pick rewrite is faster than
before the fallback existed: dungeon 96x96 went 1952 us -> 932 us.
Seed-deterministic via GenRng, never the world rng, so a town replicates as
(kit, seed, params). Not done: walls/doors around dungeon rooms (floor-only
today), multi-cell buildings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A generated game handed box3d an infinite extent and took the whole process
down — found by the eval harness actually generating games, not by reading
code.
NaN was already absorbed (Rust's `max` returns the non-NaN operand), but
INFINITY survives it and poisons every plane normal to NaN. box3d's face query
then never beats its -f32::MAX starting separation, leaves max_face_index at
the -1 sentinel, and convex_manifold.rs casts that sentinel through u8 into
255 and uses it to index a 6-element array.
The port is FAITHFUL to upstream C here — convex_manifold.c does the same
(uint8_t)maxFaceIndex cast; C reads garbage where Rust panics — so box3d is
not the place to diverge. The boundary is: never hand the solver a value it
cannot reason about. sane_extent() clamps non-finite and out-of-range
dimensions (and density) to a workable range.
Regression test covers +inf, -inf, NaN, 0 and negative extents: none may panic
and all must leave a finite pose after 30 ticks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tools/arcade_eval drives the REAL agent through a 14-prompt suite, evaluates
each result headlessly through ScriptHost, and scores it — then the context is
improved from the aggregate failure modes and re-measured.
Dominant functional failure, now quantified exactly: game.terrain without
smooth:true spawns one static box PER CELL. Every high-entity case matched
cells^2 arithmetic precisely (96^2=9216, 64^2=4096, 48^2=2304, 40^2=1600) —
one misunderstanding causing the entity explosions, the slowdowns and an
engine crash. Racing went PANIC/2382 entities -> PASS/22.
Engine bugs found by generating games rather than by reading code:
- `input` was NIL in every on_tick (the host's NIL marker went through
unresolved), so every generated game reading input was broken
- wrong-typed options coerced silently: size:[1,2,3] -> vec3(0,0,0),
color:"#ff0000" -> grey
- `loop:` as an option key hangs the VM until the instruction limit fires —
and dispatch.rs ADVERTISED `loop` in the model-facing API text, so the
engine was telling the model to write the thing that hangs it. Removing it
from the doc string turned two eval failures into passes
- box3d panic (still live, fixed separately): convex_manifold.rs launders a
-1 "no face" sentinel through `as u8` into 255 and indexes a 6-element array
THE FINDING THAT MATTERS: every generated game looks catastrophically bare —
and so does our own hand-written 72-line model-answer fixture when rendered
through the same path (an empty green field, two dark rectangles, two white
boxes). That decisive test rules out generation failure AND documentation gap.
game.model, game.material, game.tree, game.scatter and find_model appear ZERO
times in splashgame.md and in the model-facing api_text(): the 4,400-model
Kenney library, the generated trees/rocks/scatter, and the material presets
all exist as crates with no script binding. The AI is faithfully reproducing a
bare aesthetic because bare primitives are the only vocabulary the engine
exposes. Prompt tuning cannot fix that; binding the libraries to verbs can.
Also visible in the reference capture, engine-side rather than generation:
flat lighting with very low sky/ground contrast, shadows too weak to ground
objects, no AO on primitives, and a default camera that frames poorly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
decodable is no longer gated on format — acb315614 took the in-house Vorbis
decoder to sample-exact on every shipped file, mono and stereo. The
`undecodable` reporting path stays for a future format we might index before
we can play it; the test now asserts the CURRENT catalogue is clean rather
than asserting ogg is broken.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bug was NOT residue type 2 — that lead was a reasonable inference from
"stereo-only, transient-heavy", and it was wrong. The cause was overlap-add
placement of early long blocks.
A block's window is centred on `center` and reaches n/2 either side. A file
opening [256, 256, 2048, ...] puts the first long block's centre at 832, so it
starts at -192 — before sample zero. Those leading samples lie outside the
stream and must be DROPPED. The code used center.saturating_sub(n/2), clamping
the start to 0, which slid the whole block 192 samples later. Every sample was
corrupted until the centres grew past n/2, then decoding was perfect again.
That shape is exactly why it read as a residue fault: a wrong head with a
correct body looks like "specific blocks have wrong amplitude", and
correlation averaged it to 0.82. Mono appeared flawless only because no mono
file in this corpus happens to open with an early long block — a corpus
accident, not a decoder property.
mono 47/47 exact, mean 1.00000 -> 186 files, mean 1.00000, min 1.00000
stereo 68/107 exact, mean 0.826 -> 370 files, mean 1.00000, min 1.00000
corpus 115/160 exact -> 556/556, zero decode errors
The 73 "frame-count mismatches" are afconvert trimming further than the
container specifies; afinfo's valid-frame counts match OUR output exactly and
every file still correlates at 1.0000.
The fix is extracted into a shared overlap_add because decode and debug_raw
each had their own copy — a diagnostic that can disagree with the decoder it
diagnoses is worse than no diagnostic.
New test is fixtured on a file that opens [256, 256, 2048, ...] and asserts
PER-SAMPLE agreement, not just correlation: correlation alone hid this at 0.82.
Decode cost 5.16 ms/file; 11.5 MB compressed expands to 143.3 MB of f32 PCM.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two root causes, both found by building an oracle rather than guessing.
1. Amplitude ~75x low: the IMDCT applied a 2/n normalisation the encoder's
forward transform had already carried. Because 2/n varies with block size
it produced DIFFERENT errors on 256- vs 2048-sample blocks — exactly the
reported symptom. Removing it gives fit scale 1.0000.
2. Leading trim, the real remaining defect. The first audio packet produces NO
output (its window only primes the overlap-add) but we emitted from the
first block's centre, injecting half a priming window of garbage and
shifting everything early. And Vorbis carries encoder delay in the GRANULE
POSITION, which varies per file — afinfo confirms 128 / 1103 / 960 frames
on three samples — while our Ogg reader kept only last_granule and
discarded per-page granules, making it unrecoverable. Added per-page
granule tracking: the first page reporting a granule pins priming as
centre - granule, and valid audio starts at priming + blocksize_0/2. That
reproduces afinfo's numbers exactly on all three.
A premise in the brief was also wrong and worth recording: our output length
was already correct. afinfo reports valid frames matching OUR output — it is
afconvert that trims a further 128. The reference WAV was short, not us.
mono 47 files mean corr 1.00000 (min 1.00000) 47/47 exact
stereo 107 files mean corr 0.826 68/107 exact
Decode cost 5.13 ms/file average; 11.5 MB compressed expands to 143.3 MB of
f32 PCM, which is why the sample bank's LRU cap matters.
Honest remaining defect: ~39 stereo files decode wrongly and it is NOT
alignment — a full lag sweep peaks at 0.40-0.89 with fit scales 0.40-1.87, so
specific blocks have wrong amplitude. Mono being 47/47 rules out floor,
residue 0/1, MDCT, windowing and priming; coupling matches the spec's
square-polar mapping including reverse order; floor 0 is rejected rather than
mis-decoded; and both channels are identical in the failing files, so it is
not a swap. The failing set is transient-heavy impact/footstep sounds, so the
lead is residue type 2 partition counting on short blocks.
reference_decode.rs is no longer #[ignore]d: 3 real tests asserting mono
correlation > 0.999 and length == granule, plus a 3000-mutation fuzz that must
never panic, all skipping cleanly without fixtures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
Seed-deterministic, baked at spawn, emitted straight into the 24-byte packed
vertex format. Two devices given the same seed produce byte-identical
geometry — which is what lets a forest replicate as (preset, seed, position)
tuples instead of mesh data.
- L-system plants: expansion + 3D turtle + skeleton, 8 species (oak, pine,
palm, bush, fern, cactus, dead, grass)
- Surface nets (not marching cubes — fewer, better-shaped triangles at
low-poly) for rocks, boulders, mushrooms, clouds, blobs
- Spline tracks with width, banking, curbs and rails, returning centreline
frames that carry lap distance — so spawn points and checkpoints derive
from the track instead of a second hand-written list
- Poisson-disk scatter with flatness/height rules
- Texture generator with CPU mip chains (backends never generate them)
- LRU cache keyed by FNV-1a over the full recipe with floats hashed by exact
bits; -0.0 and 0.0 normalise together (identical geometry), 4.0 and
4.000001 stay distinct. Meshes hand out as Rc so eviction cannot pull
geometry out from under a frame mid-draw
- DrawGameFoliage: growth and wind as an OPT-IN shader variant, a sibling of
DrawGameSkinned rather than a flag inside the shared shaders — wind costs
~20 vertex ALU and the cube shader draws most of the world. Growth and flex
weights share one packed nibble pair, so both animations cost zero extra
vertex bytes
A realistic forest — 150x150 m, 582 trees, 3 species, 6 seeds — generates in
1.70 ms with 470 KB resident: 6 generations and 576 cache hits.
Three bugs the unit tests had passed, found by writing an ASCII silhouette
probe because captures were out of scope: every species came out ~4x its
requested height (the test compared two sizes RELATIVELY, so a uniform
overshoot sailed through — now the finished skeleton is measured and rescaled,
and the test asserts absolute height for all 8 species at three sizes); palm
emitted no foliage at all because its L-system contained no leaf symbol; and
cactus sprawled sideways like a shrub. Pine also dropped from 4 iterations to
3 — 15032 -> 2504 triangles, 728 -> 68 us — because 15k triangles for one
background tree is indefensible.
Honest caveat: that is a silhouette judgement, not a rendered one. Shading,
leaf-card orientation and the wind/growth animation are visually unverified.
Script verbs are not wired yet — the crate is a library only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The compiler is where hostile shaders get stopped, because this engine lowers
the script shader language to Metal/HLSL/GLSL/WGSL rather than passing source
to a driver. Two genuinely exploitable holes found and closed:
- Loop bounds were arbitrary EXPRESSION STRINGS written straight into the
emitted shader, so `for i in 0..some_uniform` compiled to an unbounded GPU
loop and `loop{}` emitted a bare `while(true)`. A hang triggers a driver
device reset that kills the app — ugly anywhere, worst in a headset. Rather
than reject (which would break legitimate code), the bound is now EMITTED: a
provably-small integer literal compiles unchanged, anything else gets a hard
65536-iteration cap. literal_bound() is deliberately conservative — a
uniform, arithmetic, a call, hex or a negative all count as unprovable
- compile_fn INLINES at every call site, so a branching call graph expands
exponentially with depth (recur_block stops self-recursion, but not f1
calling f2 twice calling f3 twice). MAX_EMITTED_BYTES (1 MB) bounds what was
an unbounded compile-time DoS
Recursion was already safe (recur_block errors); nothing added there.
Honest remaining gap: the cap bounds ITERATIONS, not cost per iteration — a
shader doing 65536 heavy texture samples is legal and slow. Bounding real GPU
time needs a cost model or driver watchdog; neither exists here and this does
not claim otherwise.
Verified empirically, not just by compiling: all 24 loop{} constructs in the
built-in shaders are guarded (48 emitted lines, 4 unique guard names per
shader confirming no shadowing), zero for( loops exist in any built-in, and
the capture shows rendering intact — including text, which is exactly where
those guarded loops live.
MEASURED FIRST, then declined to build: 34 shaders compile at Arcade boot in
~15 ms total on Metal (first 4.47 ms cold, rest 0.10-0.74 ms). A persistent
shader cache is NOT worth its invalidation-and-staleness surface to save 15 ms
of one-time boot, so it wasn't built. Vulkan/GLES on Quest may differ — that
needs on-device measurement before anyone builds speculatively. Benchmark
retained behind MAKEPAD_SHADER_BENCH (it was logging every boot);
MAKEPAD_SHADER_DUMP=<dir> writes generated source, which is what an AI has to
debug and was otherwise invisible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Two bugs, one visible symptom — the Knight strolling through the crate stack.
1. Rigid bodies were absent from the mover sweep set and from raycast /
camera-boom queries. When box3d dynamics landed, movers kept sweeping only
against Static|Kinematic, so a character (and a bullet, and the chase
camera) passed straight through every crate. Rigid poses are read back from
box3d at the end of the previous tick, so at snapshot time a rigid is as
settled as a kinematic and belongs in exactly the same set.
2. Fixing (1) exposed a deeper one. Clamping left the two boxes EXACTLY flush
(|d| == sum of halves), where float error decides the next axis' overlap
test either way — and a "yes" sent the falling mover UP onto the crate,
straight through the documented 0.55 step-up limit. It then walked along
the crate top. CONTACT_SKIN (1e-3) makes resting contact stop a hair short
of flush, so contact is unambiguous instead of borderline. Verified: a
walker into a 1.0-tall crate now stops at its face (x 1.20, y 0.50) rather
than climbing to y 1.50.
mover_is_blocked_by_a_rigid_body pins the behaviour. The mover golden hash is
re-baselined once, deliberately — the skin shifts every clamped position by
1e-3, and the movement it now describes is correct rather than merely
different. The reason is recorded at the assertion so a future change to that
hash needs the same justification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
libs/game/audio (77 tests): WAV decoder (8/16/24/32-bit int, f32/f64,
total on malformed input), Ogg container with packet reassembly, sample
bank with on-demand decode + linear resample + LRU eviction and pinning so
a playing voice can't be evicted, and a 24-voice mixer with equal-power
pan, playback-rate pitch, click-free fades, priority+age voice stealing and
a limiter. Generation-tagged voice handles mean a stale handle cannot
retune a reused slot. The limiter test caught a real bug: peak was being
measured per-voice, but what clips is the SUM.
Emission is the point — sounds come from the engine observing gameplay,
not from script calls: Material/MaterialPair (order-independent, the softer
material names the sound), an impact curve mapping closing speed to
gain/pitch, and an AudioDirector with repeat-avoiding variant selection,
per-category volumes, per-pair cooldowns and a per-frame cap. A 200-contact
frame yields <=6 sounds and the cooldown map is proven not to leak.
Blocks now emit their own audio: car engine tracking revs, skid on lateral
slip, suspension thud on landing; character footsteps timed off the WALK
CYCLE rather than a timer, so feet and sound stay together when slowing;
jump/land scaled by fall speed; plane engine by throttle; lap and win
stings. RNG isolation proven: heavy audio work interleaved with world-rng
draws leaves the drawn sequence bit-identical to a silent run.
KNOWN GAP, reported rather than hidden: every Kenney audio pack is Ogg
Vorbis only (471 files, zero WAV), and the from-scratch Vorbis decoder is
NOT correct yet — setup header parses exactly, channels/rate/frame count
and envelope shape are right, but floor magnitudes come out ~75x low and
best correlation against an afconvert reference is 0.67. Two real bugs were
found and fixed en route (type-1 residue filled one codeword instead of the
partition; MDCT post-twiddle carried the pre-twiddle's 1/4 term). The
reference test is committed as #[ignore] with its measurements in the
message so it stays runnable. Ogg stays flagged unplayable in the asset
index; WAV and the --transcode path work today.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
Desktop entry runs it under studio like the other apps; the quest entry
uses the same RunQuest wrapper as makepad-example-xr, which M3 verified
packages an arm64-v8a APK with the passthrough/handtracking manifest bits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>