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>
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>
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>
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>