The GrantPermissionsActivity pops up during navigation and blocks the
app's event loop, preventing hub responses. Pre-grant all runtime
permissions after APK install to avoid this.
PID 28203 (rs.robius.robrix) was the actual zombie reclaiming foreground
and killing our test app - not our own package. Force-stop both the
target package and known interfering Makepad apps (Robrix) during test
setup to prevent cross-app foreground competition.
Also remove the pm disable-user approach as it doesn't help against
a different package's zombie process.
Samsung devices keep killed app processes alive and bring them back to
the foreground ~15s later, killing our fresh test instance. force-stop
and kill -9 don't prevent this. pm disable-user fully prevents the
zombie from being restarted. Re-enable before launching the new instance.
The Android platform never sent BeforeStartup or AfterStartup messages
via the studio websocket. Desktop platforms send these through their
stdin event loops, but Android uses websockets instead of stdin.
Without AfterStartup, the hub never broadcasts AppStarted to UI
clients, causing makepad-test to time out waiting for app startup.
Adds feature-gated optional deps and re-exports (makepad-test, makepad-csg,
makepad-gltf, makepad-mbtile-reader, makepad-fast-inflate) so a downstream
workspace can depend on makepad-widgets as its sole Makepad source.
Adds the Android test runtime to makepad_test: builds the APK with
cargo-makepad's standard Java path, installs and launches via adb with
makepad.STUDIO_* intent extras (incl. STUDIO_BUILD), connects the app to an
in-process hub over adb reverse, and waits for startup + responsiveness.
Adds clean in-process hub shutdown (HttpServerHandle + GatewayHandle Drop)
and the STUDIO_BUILD intent parsing on the app side. No native-activity or
NDK APK compilation code is included.
Vec::pop inverted CreateWindow/prepare/IME sequences; VecDeque pop_front matches causal order, and SetTopmost defer no longer livelocks on an empty Windows queue.
Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
A Rust panic raised while handling an iOS event unwinds out of the
`extern "C"` ObjC callback that delivered it, so the process aborts with
a bare SIGABRT. Phase-2 unwinding pops every frame between the panic and
that callback, and the default panic hook writes to stderr, which goes
nowhere on a device. Nothing about the panic survives: the TestFlight
report that prompted this shows only `abort` under
`-[UIWindow _sendTouchesForEvent:]`, with no message and no panic site.
* Install an iOS panic hook in `Cx::event_loop`, mirroring the Android
one, that logs payload, location, thread and backtrace through
`error!` (NSLog on iOS). Hooks run at the panic site before unwinding
starts, so this captures the location the abort would otherwise erase.
* Wrap the app callback in `IosApp::do_callback` in `catch_unwind`.
That's the one choke point every ObjC callback funnels through, so it
covers touches, presses, timers, draws and text input at once. It also
has to be the place: `do_callback` `take()`s the callback and only
restores it on the normal path, so catching any further out would
leave the app alive but permanently inert.
* Restore `Cx::executor` in `event_loop`'s callback even when a spawned
task panics. It's `take()`n the same way, so without this the catch
above would turn one abort into an `unwrap` panic on every later
event. The panic still propagates once the executor is back.
macOS and tvOS take the executor the same way, but neither catches, so
the process dies either way and there's nothing to restore it for.
Android builds passed `-C prefer-dynamic` unconditionally, so Rust shipped
`std` as a separate `libstd-<hash>.so`. That library is a rustup prebuilt whose
LOAD segments are only 4 KB-page aligned (`p_align 0x1000`), so it can't be
mapped on the 16 KB-page devices Android 15 allows. Google Play requires apps
targeting API 35+ to run there, so release apks were failing that bar even
though `libmakepad.so` itself was already linked with 16 KB alignment.
Only debug builds keep `prefer-dynamic` now, where the faster incremental
relink is worth having and nothing ships. Everything else links `std`
statically, which drops the separate library entirely and leaves a single
NDK-linked, 16 KB-aligned `.so`. The aab path already did this.
Two knobs, both defaulting to exactly what happens today.
MAKEPAD_TEST_PARALLEL opts out of the global TEST_MUTEX. Every test
currently takes that lock for its whole body, so `--test-threads=N` has no
effect at all and there is nothing in the API that says so. Serial is the
right default — each test drives a whole app process, and oversubscribing
the machine makes timing-sensitive assertions flaky — but it should be the
suite's call.
MAKEPAD_TEST_PUMP_TICKS sets how many Ticks are forwarded before each
query. Each one costs the app a full rendered frame whenever anything is
dirty, so the hardcoded 3 is a 3x multiplier on the cost of every
`widget_snapshot()`, which is the single most common thing a test does.
Reporting the measurements honestly, from a 55-test suite downstream:
- Parallel at 4-way took it from 67 min to 11-20 min, but 2-3 tests failed
per run and the SET changed between runs — load-induced, not specific
tests. Useful for local iteration, not something to turn on by default,
which is why it is opt-in and documented as such rather than flipped.
- PUMP_TICKS=1 measured 1.47x on a fixed 10-test slice with no failures,
but broke one drag-and-drop test elsewhere in a way I could not explain,
so treat it as a tuning knob to try rather than a free win.
The flakiness above is a property of tests that wait by counting polls: how
much wall clock and how many frames a poll buys both change under load. That
is worth fixing in the tests, not by keeping the lock.
The headless backend compiles each shader to a cdylib with `rustc -O` and
writes it to a path keyed by the hash of the generated source — then
recompiles all of them from scratch on the next process start, ignoring
what it just wrote. For host_launcher that is 68 shaders and 38.7s of
startup, paid again by every process. A headless test suite starts one
process per test, so it was paying it 55 times.
Reuse the dylib when it's already there, falling back to a compile if it
won't load or has no version symbol (truncated by a killed run, built by
another toolchain). Compiles now land on a pid-private path and get
renamed into place, so a crash can't leave a half-written dylib behind.
Cold start goes 38.7s -> 1.2s.
Two more things that only bite headless:
- The BGRA->RGBAf32 texture conversion cache was rebuilt per frame, so
the whole glyph atlas was re-converted on every draw: 430ms of a 840ms
frame. It already carries a signature and honours pending updates, so
it was always meant to outlive a frame — park it on CxOs. 430ms -> 29ms.
- Headless dpi was pinned at 2.0, i.e. 4x the pixels through a SOFTWARE
rasteriser. Still 2.0 by default, since a screenshot should match what a
display shows, but MAKEPAD_HEADLESS_DPI=1 lets a suite that only asserts
logical geometry do a quarter of the work. Raster 385ms -> 96ms.
Together a steady frame goes 840ms -> 127ms. Also reports texture time in
the MAKEPAD_HEADLESS_PROFILE line, which is how the atlas cost showed up.
* Android: silence unused VA/OpenXR warnings
Exclude desktop-only va_dmabuf_modifier from Android/OHOS builds, cfg-gate
gpu_texture pool imports, and fix OpenXR repaint locals unused without Vulkan.
* Move D3D11 texture COM calls into os/windows helpers for windows_strip
* regenerate windows-rs by windows-strip
* Windows: use overlapped custom chrome with extended client area
* Windows: fix overlapped chrome init sizing and avoid DWM work on every NCCALCSIZE
---------
Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
`Area::Rect` holds a `rect_id` into its draw list's `rect_areas`, plus the
`redraw_id` of the draw that created it. A widget that hands out its area and
is then redrawn with fewer rect areas leaves that id past the end, so
`clipped_rect()` panicked with an out-of-bounds index (seen from robrix as
"the len is 105 but the index is 173" while hit-testing a message's children).
`rect()` already guarded this with `redraw_id`; `clipped_rect()`, `abs_to_rel()`
and `set_rect()` did not. All four now check the generation first and use
`get`/`get_mut` instead of indexing, falling back to the same values they
already return for an unknown area.
* fix a pile of splash script-vm bugs: newline statements, short-circuit args, tail calls
went through the script VM and parser and fixed a batch of correctness bugs
that were biting the launcher's mini-apps:
- newline-delimited statements: a `(` or `[` at the start of the next line no
longer greedily glues onto the previous value as a call/index. leading infix
operators and `.` still continue the expression (the shader DSL needs that),
and the divert is suppressed inside ()/[] groupings.
- short-circuit `&&`/`||` used as a call argument no longer loses its value to
nil when the jump skips a multi-op right-hand side.
- a call as the very last statement of a script actually executes now, in both
end-of-parse unwind loops (also patched a zero-offset ShortCircuitEnd).
- custom widgets that deref to a base with a #[source] field now forward
script_source, so script_apply_eval works on them instead of silently no-op'ing.
plus regression tests for the newline and short-circuit cases.
* harden splash isolates: scoped timers, net gating, effective-visibility snapshots
isolate-safety work so mini-apps can't reach outside their sandbox:
- isolate-safe script-timer dispatch hook + gc for stale timers
- gate net.socket_stream on the net runtime being present
- widget-tree snapshot reports effective visibility (a widget counts as hidden
if any ancestor is hidden)
- macos_activate_app (plus a headless no-op) so the launcher can focus itself
* widen the host->splash surface: splash setters, view/glassbutton script calls
everything the host needs to poke into a running mini-app's script:
- Splash: call_script_fn, set_script_global, set_allow_net, and a cached body id
so host->script calls don't rescan for the body every time
- View.set_visible and GlassButton set_text/text are callable from script now
- makepad_test learned right-click (secondary button) so the headless tests can
exercise long-press / context menus
* fix small-size glass lens + sdf box degeneration, warn on missing glyphs
visual correctness fixes we kept tripping over:
- cap the gauss lens band at 35% of the surface's smaller side so tiny discs
degrade gracefully instead of smearing
- clamp the Sdf2d.box (and box_x/box_y/box_all) radius so an oversized radius
saturates at a circle instead of collapsing into a rotated diamond
- log once per codepoint when no loaded font can render it (was silently
drawing .notdef boxes)
* guard stale rect areas in clipped_rect/abs_to_rel/set_rect against out-of-bounds panics
* add switch_finger_capture to hand a live finger capture between widgets mid-drag
* add promote_finger_capture_over: hand a child-grabbed finger up to a co-capturing container
* splash: add validate_splash_body, a dry-run eval for externally-sourced scripts
evaluates a body in a throwaway isolate with the exact prefix/limits the
Splash widget uses and returns the captured script errors instead of logging
them. lets hosts that install source from outside (downloads, AI generation,
user input) reject bad scripts with real errors to show or feed back, where
the widget's own eval silently keeps the old view.
* strip mod.res from splash isolates; document validate_splash_body caveats
the res module's handles reach both the filesystem (abs_path loads) and the
network (web_url / http resources) without going through the gated net
runtime, so a 'no-net' isolate could still fetch and exfiltrate. found by an
adversarial review of AI-generated app installs, but it applies to any
untrusted splash source.
also note on validate_splash_body that the instruction limit bounds compute,
not heap growth, and that top-level timers live until isolate reclamation --
same exposure as actually installing the source, so validation adds nothing
new.
* splash: jailed per-app file storage (mod.fs inside isolates)
mini-apps get an OS-style private data directory, like an android app's
internal storage or an iOS container: the app sees a filesystem rooted at
"/", and that root IS its host-assigned sandbox directory
(Splash::set_sandbox_dir / SplashRef forwarder). registered as mod.fs in
isolates -- deliberately shadowing the stripped real fs module, so inside
an app "the filesystem" simply is the jail:
fs.read fs.write fs.append fs.exists fs.remove fs.mkdir fs.list
containment lives entirely in the host layer:
- lexical path resolution against the root; `..` above the root, NUL, deep
or overlong paths are errors before any I/O
- the per-VM root is rust state keyed by the isolate's heap -- script code
can neither read nor retarget it
- symlink defense in depth: nothing here can create links, and every
existing component under the root is verified non-symlink before use
- quotas: 1MB/file, 16MB/jail, 256 entries
- no root assigned (previews) -> every call errors cleanly
validate_splash_body gives dry runs a throwaway jail (temp dir, removed
after) so top-level fs.read boot loads validate instead of erroring. roots
are dropped with their isolates in the gc.
unit tests cover the containment: traversal/absolute/backslash escapes,
depth/name caps, and the symlink block.
* splash: put the jailed fs module in scope as a bare name
app scripts say fs.read("/x"), but the eval prefix only used the widgets
prelude, so bare fs resolved to a not-found error value and every storage
call failed silently. bind it in the prefix (let fs = mod.fs) for both the
plain and net variants; a script reassigning fs only shadows its own name,
the jail stays host-side.
* splash storage: quota + boundary hardening from adversarial review
three confirmed jail findings:
- mkdir bypassed every quota (target + create_dir_all, no jail_usage check)
-> unbounded inode/dir-metadata exhaustion on the shared host volume.
now charges new dirs against MAX_ENTRIES via missing_entries(); write's
entry check does the same so a deep write can't overshoot the cap either.
- write/append/mkdir lacked remove's root guard: fs.write("/", data)
resolved real == root and reached create_dir_all(root.parent()) -- one
dir above the jail (the shared app_data/). now rejected like remove does.
- validate_splash_body's scratch jail used a predictable temp name created
with create_dir_all (would follow a planted symlink out of temp). now an
exclusive create_dir on a per-process+vm name (EEXIST-safe against a
planted entry), reclaimed via gc before the dir is removed so a top-level
timer can't resurrect it.
unit tests added for missing_entries; the containment tests still pass.
* splash: empty set_text tears down the isolate instead of no-oping
set_text("") was a silent no-op (eval_body early-returns on an empty body),
so a reused Splash that goes back to empty -- the widget-gallery live preview
on Back -- left its old isolate running its timers (and holding a storage-jail
binding) behind a blank view. now an empty body reclaims the isolate: the
isolate-minted view is replaced with a fresh empty one built in the main vm
BEFORE the isolate heap is freed, then the isolate is gc'd (stopping its
timers, dropping its jail root); vm_id resets to MAIN so a later non-empty
set_text allocs a fresh isolate as before. the existing host_launcher
teardown call sites (widget picker back()/reset()) become correct unchanged.
* overlay: composite glass in draw order, not creation order
every gauss/glass surface opens its own draw list and registers it in the
window's single Overlay via store_sub_list, which hands out the first free
slot and keeps it for the life of the process. renderers walk that table in
index order, so the paint order of all glass in an app was the order the
surfaces were first *created* — permanently, with freed slots reused by
whatever registered next. draw order never came into it, so a widget rebuilt
after a layout change, or a panel opened later, could land on top of anything
drawn after it. the only workarounds available to apps were "don't draw the
thing that's winning", which looks like a bug.
the hook for fixing it was already there and unused: CxDrawList's
draw_item_reorder, honoured by every backend (metal, d3d11, opengl, vulkan,
web_gl, headless raster). so stamp each overlay sub-list with the position it
was begun in this frame (Cx2d::overlay_seq, reset in Overlay::begin) and have
Overlay::end stable-sort the table by that stamp.
this also gets parent-then-child right without special cases, which matters
because glass.GlassButton / glass.GlassSegmented call begin_overlay_reuse
unconditionally instead of checking is_drawing_overlay(), so they hold their
own slots rather than riding their parent's.
* glass.GlassSegmented: size segments to their labels, add set_selected
three things, all of them things that looked broken to a user:
- segments were width/count, so "Max" got the same room as "Default": the long
word crowded, the short one floated. each segment is now measured (DrawText
layout size_in_lpxs) and gets its text plus padding, with leftover width
shared equally so every label keeps the same margin. if the labels don't fit,
the padding shrinks (never the text) to a floor. the pill's x/width are
computed in rust and passed as uniforms since they can't come from a segment
count any more, and hit-testing is a boundary lookup rather than a division.
- `selected` was public but the pill is drawn from a private sel_pos that only
followed it via the click animation, so restoring a saved value from code
left the control showing one segment while reporting another — and a click on
the segment it really held was then ignored as "already selected". that reads
as the control eating your clicks. set_selected keeps both in step.
- the travel easing was 0.30, which arrived before the eye could follow it.
0.16.
* text_input: re-layout when max_lines changes
the laidout text was cached on width alone, so flipping draw_text.max_lines
at runtime (collapsing a composer to one line) kept the old multi-row layout
and the field never shrank. make max_lines part of the cache key.
* text_input: add set_max_lines instead of making callers script it
applying script to a TextInput re-applies its #[live] fields, and text is
one of them, so toggling max_lines through script_apply_eval! silently
wiped whatever the user had typed. give it a typed setter.
* text_input: don't drop the layout in set_max_lines
clearing laidout_text there leaves the field with no layout for the rest
of the event batch, so every cursor op in that window bails out with
"can't move cursor because layout was invalidated by an earlier event".
since set_max_lines gets called from focus/blur handling, that window is
exactly when you're clicking into the field — so the click placed no
caret at all. max_lines is already part of the layout cache key, so the
next draw re-lays out on its own.
* text_input: add scroll_to_top
for a field that folds to a fixed height when it loses focus: the scroll
offset survives the blur, so a draft last edited near its end folds
showing whichever line the caret had scrolled to rather than its first.
leaves laidout_text alone — scrolling doesn't change the layout, and
dropping it would break every cursor op for the rest of the event batch,
same trap as set_max_lines.
* text_input: add set_height
for a composer that folds to one line when it loses focus. pinning the
height is the safe way to fold — unlike clamping max_lines it leaves the
laid-out text alone, and the laid-out text is what maps a click to a
caret position. fold by re-layout and the press that re-focuses the
field resolves against the folded layout while the expanded one is on
screen, so the caret and any drag-selection land on the wrong text.
* text_input: add take_key_focus, which actually shows the caret
the caret draws as (1.0 - blink) * focus, and both come from animators
that only move when the widget is dealt a Hit::KeyFocus. setting key
focus on a field that ALREADY holds it dispatches no hit — so a field
that was focused, then hidden (hiding doesn't clear Cx's key focus) and
shown again comes back typable but with no caret and no selection
highlight, animators still parked where the last focus-lost left them.
plays focus.on unconditionally rather than only when focus changed:
repairing the case where it did NOT change is the entire point.
* splash: name scripts in errors, and stop using line as an identity slot
a runtime error from a Splash app logged `:1804943384:12 - widget has
no uid`: empty file, and a "line" that is really a pointer address.
the format is {file}:{line}:{col}, and both fields were casualties of
the same hack — ScriptMod.line carried self_id so the body could be
found again (m.line == self_id && m.file.is_empty()), while ip_to_loc
adds that same field to the script's real line when reporting. so every
location came out as real_line + a pointer, and nothing said WHICH app.
identity moves to module_path, which nothing else reads for these
bodies, freeing line to be a line. file gets a real name via a new
set_debug_name the host calls with the mini-app's id.
the validator's ScriptMod gets the same treatment — its errors are
shown to the user AND fed back to the agent as repair input, so a
location offset by a vm id was actively misleading there.
* splash: document the constant offset in reported script lines
the host prefix is two lines, so a reported line is two ahead of the
app's own file. it can't be zero — a zero-line prefix would share line 1
with the app's first line, and that line is always the // name: header,
which would comment the prefix out.
* script: stop silently losing widgets emitted from branches and loops
Splash mini-apps kept rendering nothing from on_render closures with zero
errors logged. Bisected live and in pure-VM probes, this was a pile of
separate bugs in the same corner:
- an if/else whose branch emits a widget lost the taken true branch: the
statement's POP_TO_ME got fused onto the else tail, which the IF_ELSE
jump skips. Generalized last_short_circuit_target into last_jump_target
and record every branch join (if/elif/else, match, try/err), so the
commit lands standalone AT the join and every path runs it. Same disease
as the short-circuit-argument bug, new jump sites.
- elif never patched its arm's IF_ELSE jump (relative 0), so a taken arm
spun the interpreter until the instruction limit killed the whole entry.
elif now desugars into else { if ... } via IfElseExpr, which also gives
its arms the join treatment.
- for x in <non-iterable> silently skipped the body where the equivalent
while errored; now raises "for loop source is not iterable" (nil and
empty sources stay silent). for k v in <number> passed key/index swapped
and lost k after the first iteration (the advance only rebound v).
- a line-leading { after a value-ending line glued onto the previous
expression as a proto instantiation; it now starts a new statement,
same divert rule the ( and [ newline fix added.
- int literals were second-class numbers: U40/I32/F32/F16/U32 didn't
collapse to the number bucket in to_redux, so 6 .is_number() missed
method dispatch entirely and an int arg against a float default failed
with "expected number, got number". They're all just numbers now.
Regression tests in tests/on_render_emission.rs cover each shape, incl.
the exact calendar/weather patterns that were blank in the launcher.
* splash: keep an on_render closure's final widget, and say when a render fails
Two host-side halves of the emission-loss story:
- the parser turns a closure's last statement into its return value, so a
render closure that ENDS with a widget literal built it and then threw it
away (this is why wrapping a whole render in one extra View{} produced
nothing). script_result now pushes a returned widget object into me as
the last child; non-widget returns still get skipped downstream.
- a render closure that errors mid-run used to have its output discarded
with no diagnostics at all, which is what made this whole bug family
cost days to find. Now it logs the error before dropping the result.
* script: auto-close still-open fn and let states at end of source
Both parse drivers dropped EndFnExpr/EndFnBlock/EmitLetDyn through their
auto-close catch-all when the source ended with them still open. A module
whose FINAL statement was let c = <lambda> got a body whose jump-over
stayed 0 — FN_BODY_DYN re-ran, found its me already popped, logged
"me stack is empty" and fell straight INTO the body, running it inline
at definition time and ending the module eval early — and the let itself
never emitted, so the binding silently didn't exist (same for
let c = <call>). Now those states close the way the live handlers do:
return + jump patch for the body, LET_DYN/LET_TYPED for the binding, and
the let's own EndStmt no longer marks a statement value (LET consumed
it; the final RETURN would pop an empty stack).
Fun consequence: the old test idiom of reading a result via
let out = r
out
only ever worked BECAUSE the trailing let was dropped — RETURN popped
the naked value off the stack. With the let actually binding, scripts
must end on a call (echo(r)); the emission tests are updated to do that.
Regression tests in tests/auto_close_eof.rs, including the exact
deferred-boot-timer closure shape the launcher apps use (which was
already fine — it just LOOKED guilty, see the splash commit).
* splash: probe optional script hooks without spamming the error log
call_script_fn checks whether the fn exists and bails quietly — but it
probed with a trapping scope_value, which had already queued a NotFound
by the time the miss was handled. Every host broadcast of an optional
hook (on_app_resize, on_widget_resize) against a script that doesn't
define it logged
variable 00001e93e419c77c not found in scope
— maximally misleading: the hex is just id!(on_app_resize) (Rust-side
ids aren't in the reverse-lookup table so they print raw), and the
line:col is the stale ip from the end of that script's eval, which
pointed at whatever closure happened to be compiled last. In the
launcher that was the boot-timer line of every generated app, sending
the investigation down a deferred-closure rabbit hole the pure-VM tests
then cleared. Probe with NoTrap.
* script derive: don't name the eval values vec 'v'
script_apply_eval!'s generated values block bound 'let mut v' and then
spliced #(expr) interpolations in verbatim — so a caller interpolating a
variable that happened to be called v got the macro's own half-built Vec
(borrow errors if you were lucky, the wrong value if not). Obscure name
instead.
* headless: don't compile the Apple video path
Upstream's zero-copy video work put CoreVideo/Metal code in
gpu_texture.rs (plus two consumers) behind cfg(target_os = "macos")
alone. Under cfg(headless) the apple backend isn't built at all, so
every one of those symbols — ObjcId, msg_send!, CVPixelBufferRef,
CVMetalTextureCache* — is missing and makepad-platform fails to
compile with 94 errors. That takes the headless harness down with it,
which is what host_launcher's UI tests run on.
Gate the Apple blocks on not(headless) too. Nothing is lost: headless
has no Metal device to import a CVPixelBuffer into, so the whole path
is inapplicable there.
Not caused by the rebase — pristine dev has it: its headless
CxOsTexture is an empty struct while gpu_texture.rs reads .os.texture.
* PortalList: make `smooth_scroll_to_end` actually reach the end, smoothly
Two separate bugs made robrix's jump-to-bottom button unreliable.
* `smooth_scroll_to_end` multiplied the caller's speed by `range_end`, but that
value is the per-frame pixel delta handed to `delta_top_scroll`. On a list of
a few hundred items it became tens of thousands of pixels per frame, so the
scroll completed in a single frame and read as a hard jump. The longer the
list, the more instant it got. Pass the speed through unchanged; the
`max_items_to_show` teleport already bounds how far the animation has to run.
* `smooth_scroll_to` treated "the target's top is somewhere in the viewport" as
already arrived, and returned after emitting `SmoothScrollReached` without
scrolling. When the target is the last item that is wrong: a tall final item
can have its top on screen with most of it below the fold, so the button did
nothing or stopped part way. Targeting the last item now settles only once
`at_end` is true, which is the same boundary condition the in-flight
termination check already used.
* PortalList: don't let a press or a jump-to-end silently stop tailing
`auto_tail` pins the list to the end via `tail_range`, and every scroll path
that clears it also sets `detect_tail_in_draw` so the next draw can turn it
back on if we're still at the end. Two paths cleared it with no way back.
* `Hit::FingerDown` cleared `tail_range` and never armed the re-detect, so any
press on the list stopped tailing for good, including the press that begins a
drag that ends back at the bottom. Clicking a message was enough.
* `smooth_scroll_to_end` left tailing off at the end of its own animation, so
the jump-to-bottom button parked the user at the bottom without resuming
tracking. Since the button is only shown when not at the end, this was a
reliable way to end up bottom-flush and no longer tailing.
* PortalList: actually animate `smooth_scroll_to_end` from anywhere in the list
It only looked smooth when you were already near the bottom. `smooth_scroll_to`
teleports its anchor to within `max_items_to_show` (20 by default) of the target
before animating, so from further up, most of the travel happened in one frame
and only the last few items were ever animated.
* Pass an unbounded window from `smooth_scroll_to_end`, so the anchor isn't
teleported and the whole distance is animated.
* Scale the per-frame delta to the remaining distance so the animation takes the
same time whatever the list length, instead of crawling at a fixed rate.
* Target `range_end - 1`. `range_end` is exclusive, so scroll-to-end was naming
an index with no item behind it, which left `at_end` as the only thing that
could end the animation.
* PortalList: resume tailing when a scroll-to-end finishes, not when it starts
`smooth_scroll_to_end` armed `detect_tail_in_draw` up front, but the draw that
consumes it happens on the next frame, while the animation is still running and
`at_end` is still false. The arm was therefore always spent before we arrived,
so jump-to-bottom left the list at the end without tailing and the next message
wasn't revealed. Manually scrolling down worked because those paths arm the flag
on a frame that is already at the end.
Arm it where the scroll actually lands instead: both when `ScrollingTo`
terminates and on the immediate-settle path, in each case only when the target
was the last item.
* platform: expose the capturing area of a touch by uid
Raw `Event::LongPress` handlers (it has no `handled` field and is
broadcast to every widget) had no way to tell whether the press was
captured by an unrelated widget, so a geometric containment check
alone could steal a press that e.g. a dock `Splitter` owns.
* add `CxFingers::touch_capture_area(uid)`, mirroring the existing
public `is_area_captured`
* platform+widgets: add `Event::ClearHover` to reset stale hover state
An overlay (context menu, modal) claims all pointer hits while open, so
widgets that were hovered or pressed when it appeared never receive the
`FingerHoverOut`/`FingerUp` that would clear their visuals: makepad's
hover slot holds exactly one area, and once a widget loses it, nothing
is ever delivered to it again. Apps had no way to reset widgets they
don't own.
* `Cx::queue_clear_hover()` requests a one-shot `Event::ClearHover`
dispatch after the current event and its follow-up actions finish
(drained in `call_event_handler`, mirroring
`handle_pending_window_geom_changes`)
* `View` plays `hover.off`/`down.off` on it when its animator is
defined, `Button` cuts `hover.off`, `HtmlLink` cuts its link hover,
and `CalloutTooltip` hides itself
* overlays call `cx.queue_clear_hover()` when they close
* platform: rename `queue_clear_hover` to `clear_all_hovers`
Name the effect rather than the mechanism, matching `set_key_focus`
and friends, which also defer their commit internally.
* platform: expose `Event::pointer_claimed_area` for claim bracketing
A widget cannot tell whether the area that claimed a pointer event
belongs to one of its own descendants or to an unrelated widget drawn
on top of it; geometric containment is a false proxy (a floating
overlay's rect can sit entirely inside a larger widget's rect).
Dispatch order answers it exactly: dispatch IS the widget tree
traversal, and claims are first-wins, so a claim that appears between
a widget's own `handle_event` entry and its end was made by that
widget or a descendant, while a pre-existing claim belongs to a widget
drawn above it.
* add `Event::pointer_claimed_area()`, reading the claim cell of
MouseMove/MouseDown/TouchUpdate(Start); snapshot it at dispatch
entry and compare after
* macos: fix present-gate watchdog race that could re-wedge the runloop
The watchdog's `store(0)` assumed overdue presented-handlers were lost,
but they are usually just late: each one that fired after the reset
stole a decrement belonging to a newer frame, so `in_flight_presents`
could transiently read lower than the true number of outstanding
drawables. A deficit coinciding with a compositor consumption stall let
the gate open onto an exhausted pool, and with
`allowsNextDrawableTimeout:NO` the `nextDrawable` call blocked the
single runloop forever (paints, input, and the watchdog all share it),
until an external window resize freed a drawable.
* pack a reset generation into the high 32 bits of
`in_flight_presents` (count stays in the low 32)
* the watchdog reset now bumps the generation as it zeroes the count
* each presented-handler captures its generation at arm time and
no-ops if a reset happened since, so late handlers can no longer
steal decrements and the gate closes exactly when the pool is full
* macOS: gate the present pacer at 3 in-flight presents, not 2
The drawable pool holds three, so acquiring one with two still outstanding
can never block. Gating at 2 made the pipeline effectively single-buffered:
when a GPU frame ran longer than one beat it locked into a submit-wait-submit
resonance (~20fps at 0.6ms of CPU work), and it tripped the 250ms stuck-gate
watchdog a full frame earlier than it needed to.
`macos_freeze_android_gc` has gated at 3 since `ee96a23a3`; this brings the
branch that robrix pins in line, using a named `PRESENT_GATE_IN_FLIGHT` const
so both skip sites stay together.
* macOS: stop a stale `occlusionState` from freezing the UI forever
`handle_repaint` skips the beat while the window reports itself hidden, and
that skip was the one path with no timeout: its only exit was the flag
flipping back to visible. macOS can leave the visible bit clear on a window
that is really on screen (it comes back after a resize, which is why resizing
a wedged window has always cured it), and when that happens we skip every
beat forever. The runloop stays alive and input keeps working, so it looks
exactly like a frozen UI rather than a hung app.
* Bound the skip. After `OCCLUSION_PROBE_INTERVAL` (2s) of skipping we fall
through and present anyway. If the flag was stale we recover on our own; if
the window really is hidden we spend one frame per 2s to find that out.
* Make the pool rebuild actually rebuild. The watchdog nudged `drawableSize`
to `h + 1` and back to `h` in a single implicit CATransaction, so the layer
only ever observed the net change of nothing and could keep its stuck
drawables. `rebuild_drawable_pool()` now commits and flushes each size
separately, the way a real resize does. This also has to be right before
the occlusion probe is safe, since the probe deliberately presents a frame
the compositor may not consume.
* Log the abnormal paths. Every recurrence so far left no evidence of which
path fired, so the stuck gate now says so once, staying quiet while the
window is hidden (the probe trips that path by design every 2s).
Verified by running robrix against this branch: the probe path fires and
recovers with no panic, the UI renders normally, and a healthy foreground
run logs nothing.
* macOS: give back the in-flight count when a pass bails before presenting
`handle_repaint` acquires the drawable, bumps `in_flight_presents` and arms the
presented handler, and only then calls `draw_pass`. `draw_pass` has three early
returns (no draw list, nil MTKView descriptor, degenerate pass rect) that all
bail before `presentDrawable`, so the handler never fires and the count is over
by one forever. Two of those and the gate is permanently closed, leaving the
250ms watchdog holding the app up at a few frames a second.
`draw_pass` now returns whether it reached the present, and the caller undoes
its accounting when it didn't, guarded on the same generation as the handler so
a late handler still can't double-decrement. The other callers (iOS, tvOS,
stdin) ignore the result, which is fine since they don't count presents.
* macOS: fix two window delegate selectors that never fired
`windowChangedScreen:` and `windowChangedBackingProperties:` aren't
NSWindowDelegate selectors; the real names are `windowDidChangeScreen:` and
`windowDidChangeBackingProperties:`. AppKit never called either, so
`window_did_change_screen` and `window_did_change_backing_properties` have been
dead code, and moving a window between displays or changing its backing scale
never produced a geom event. Every other selector in that block already uses
the correct `windowDid` spelling.
- deep_eq returns false immediately when both sides are string-like and
the bit-compare failed (strings are interned, so bit-equality IS string
equality) — trims the type-check chain on the hot failed-compare path
of string-tag dispatch (a.kind == "...")
- pre-reserve thread stacks (value stack, scopes, calls, mes, loops)
- splash_bench: string_cmp workload, arith_hostmode (instruction limit +
run budget installed, the game-host configuration; measured: the two
per-instruction counters cost ~nothing, branch prediction hides them),
BENCH_ONLY profiler filter
Tried and rejected: batching consecutive value-pushes in run_core into
one dispatch iteration — the scan overhead outweighed the per-value
limit/budget checks it skipped (they were already free); reverted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Interpreter perf pass on platform/script, semantics-preserving:
- ValueMap hybrid small-map: object maps linear-scan a vec below 16
entries and spill to a HashMap above (scopes/call-args/small objects
never hash)
- compound scope assigns (+=, -=, ...) locate their slot with ONE
proto-chain walk instead of a read walk plus a write walk
- for-loop iterations clear and reuse the iteration scope in place when
nothing captured it (closure capture -> REFFED -> fresh scope); loop
scopes no longer copy the parent scope vec
- 'for i in a..b' captures end/step at loop entry when the range object
is un-REFFED; stored (mutable) ranges keep live per-iteration lookups
- trap error-queue + trap.on polled via one Cell<u8> bitfield per
instruction instead of a RefCell borrow + Option<enum> Cell read
- ADD checks the number fast path before the string check
Measured (splash_bench, medians of 3 A/B runs): array_iter -27%,
method_call -21%, array_index -21%, for-range loops -19%, object_churn
and field r/w -8..9%, fib -7%, sandbox-style composite tick -6%.
Adds platform/script/test/src/bin/splash_bench.rs (run with
cargo run -p makepad-script-test --bin splash_bench --release,
BENCH_ONLY=<name> loops one workload for profilers) and parity asserts
for the touched edges: mid-loop range end/step mutation stays live on
stored ranges, closures pin their iteration's scope, selective capture
does not leak across reused scopes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Windows: fix DPI-change problems: freezing, blank windows, drag-n-drop errors
- win32: do_callback queues re-entrant events (WM_DPICHANGED pumped inside
Present, nested WM_SIZE) instead of dropping them; the drops left dpi/geometry
stale, the window blank, and hit-testing offset after display-scale changes.
- win32: WM_DPICHANGED applies the OS-suggested rect and returns 0; removed the
duplicate-delivery compensations the lossless queue obsoletes.
- d3d11: no unbounded Present(1) block after a timed-out frame-latency wait
(DO_NOT_WAIT + paced retry); DXGI errors log instead of panicking.
- dnd: Drag/Drop events carry WindowId and are remapped via dpi_override_scale,
fixing drop-zone offset under UI zoom (Windows OLE, macOS external drops,
Wayland internal drags).
- win32: packaged-build window icons load at native shell sizes via LoadImageW.
* TextFlow: enforce `max_lines` for inline code wraps, list items, and inline widgets
`max_lines` + `text_overflow: Ellipsis` under-counted or skipped visual lines
whenever anything but a plain text run started one, so rich content (rooms-list
previews, anything with inline `<code>` or mention pills) could overflow its
line budget or hard-clip without an ellipsis.
* TextFlow: the inline `<code>` forced wrap left `is_continuation` stale, so a
wrapped code run was granted one extra row AND reported one fewer — a 2-line
overflow per wrap. The wrap now refreshes the continuation state and row
budget, is refused when no line remains, and its prediction probes measure
an unclamped layout (a clamped probe always claimed "fits on one line").
* Layouter: a non-wrapping layout truncated by `max_lines` never reported
`is_truncated` (detection was row-count only), so `max_lines: 1` labels
hard-clipped mid-word instead of showing "…". Width overflow on the
surviving last row now also triggers the ellipsis.
* TextFlow: list items charged two lines each (marker run + content run); the
marker now shares one visual line with the item's first content run.
* Inline child widgets (mention pills, images) bypassed the budget entirely:
Html now gates them on `is_content_truncated()`, charges the rows they open
via `track_inline_content()`, and holds a widget that lands on the last
allowed line in place (`Turtle::set_flow_wrap`) instead of letting the
turtle relocate it onto a row the budget can't pay for. A held widget that
still overruns the line is hidden behind a tracked clip and replaced with a
drawn "…" — the same contract text truncation has.
* TextFlow: fix row alignment and spacing for wrapped text beside inline widgets
On the single-batch text path (Windows/Linux), a multi-row text run never told
the turtle where its internal row boundaries were: every walk since the last
boundary — a bold sender on one visual row, a mention pill on the next — was
centered by `finish_row` against one merged row. Text sat a few px off the
pill baselines, rows around wrapped runs squeezed or overlapped, and an
up-centered pill on a padding-less first row rose above the clip and lost the
top of its rounded corners.
* DrawText: the single-walk path now allocates per visual row and runs
`turtle_new_line_with_spacing` at each internal boundary, keeping one glyph
instance batch. It emits a first-row walk owning the run's align entries and
a last-row walk with an empty range, so the run is never shifted twice.
* Turtle: `FinishedWalk` carries a `RowAlignRole`. A wrapped run's rows are
immovable (their glyphs share one batch): rows holding its visible text are
an `Anchor` — `finish_row_center` centers every shiftable walk on the
anchor's line, shifting a taller pill UP onto the text — while a
whitespace-only first row (a continuation that wrapped on a leading space)
is `Fixed`, so it cannot anchor a row to an invisible line. Anchor shifts
are clamped so no walk's top can rise above the turtle's clip.
* Layouter: `first_row_min_line_spacing_below_in_lpxs` is now honored (it was
stored and hashed but never read). Resumable draws pass the current row's
height plus wrap spacing plus the centering overhang, so a continuation's
second row lands where centered content beside it starts exactly one wrap
gap below the previous row — zero residual shift for uniform-height pills.
Selection capture and the `<code>` wrap probe pass the same floor so every
layout of a run shares one cache entry.
* Turtle: an anchored row returns its bottom forgiveness — the surplus its
allocation extends below the risen content — and `turtle_new_line`
subtracts it, keeping inter-row gaps uniform on both sides of anchored
rows. A turtle's final row keeps its full extent so nothing clips at the
bottom.
* uizoo: Html fixtures for line clamping and pill/text row alignment
Repro and regression fixtures mirroring a chat client's message surfaces:
max_lines clamping with inline <code> at several widths, atomic inline-widget
(pill) relocation/hold/ellipsis under a line budget, and timeline-style
pill+text rows at 9.3pt and 11pt metrics — including a CJK-titled pill and a
padding-0 first-row case that guards against clipped pill tops.
* Layouter: fill the last permitted row by grapheme when ellipsizing
When `max_rows` is set together with ellipsis truncation, word wrapping
would move a word that didn't fit off the final permitted row before
truncation ran, leaving that row ellipsized at the last word boundary
("@…") rather than at the last glyph that fits ("@quokka:…").
Word integrity is meaningless on a row that ends in an ellipsis, so lay
the final permitted row out by grapheme and let truncation cut at the
actual width limit.
* DrawText: account for walk margins when resolving a Fit max bound
The layout bound for a Fit-width walk with a relative max bound was the
raw resolved max, but the turtle's final width — and with it the clip
rect — gets clamped to that max minus the walk's outer margins. On top
of that, a Label passes the same margined walk to its inner DrawText,
re-applying those margins inside the turtle. Text laid out near the
bound therefore extended past the clamped clip, which sliced letters
off the end of untruncated text and cut the appended ellipsis down to
a single dot.
Subtract the walk's own horizontal margins from the resolved bound, and
when the enclosing turtle is itself an unresolved Fit with a max bound
(a Label sizing itself around this text), subtract that turtle's outer
margins too, so the layout bound matches the width the clip is actually
clamped to.
* uizoo: fixture for bounded pill labels ellipsizing at the width cap
Pill-like labels bounded to a fraction of the enclosing width, at
container widths that walk across the cap, plus one inline in an Html
flow. Truncation must always end in a visible trailing ellipsis, never
a bare mid-glyph cut.
* Layouter: keep word-boundary ellipsis on continuation rows
Grapheme-filling the last permitted row is wrong when that row is an
empty continuation: grapheme layout force-places a grapheme wider than
the row's remnant past the width limit with the truncation flag unset,
drawing overflowing text with no ellipsis. Finishing the row instead
truncates within bounds, so continuation rows keep the word-boundary
behavior.
* DrawText: scale the Fit max bound into layout units
The layouter works in unscaled units and row widths are multiplied by
font_scale on output, but the resolved Fit max bound was passed through
in physical units — the same mismatch max_layout_width_for_walk already
divides away. Any font_scale above 1 on a bounded label laid text out
past the clamped clip, reintroducing the sliced-tail bug the bound is
there to prevent.
* Turtle: add Base.Line, a Fit bound relative to the available line width
A static relative bound cannot express what an inline widget's inner
text actually has room for: that depends on the line the widget lands
on, its own leading geometry (icons, padding, spacing), and the
trailing insets after the text. Base.Line resolves a Fit max bound to
exactly that, with the enclosing line's flow selecting between two
measurements that are each final at the moment they are taken:
- A wrapping line can relocate the widget whole onto a fresh row, so
the bound is what a fresh row offers. Content sized to it either
fits where it is, or fits the row the widget is relocated to.
- A non-wrapping line — including one held non-wrapping by the
inline-content clamp on the last permitted row — keeps the widget
in place, so the bound is the remnant up to the line's right edge.
The measurement runs from the current turtle's content origin rather
than its pen, because a Fit max bound is evaluated twice: before the
content is laid out, and again in compute_final_size when the turtle
closes. A pen-relative measurement would collapse the closing clamp
to the leftover width and clip the content it just laid out.
* uizoo: fixtures for Base.Line-bounded pill labels
The four behaviors the line bound guarantees: a long name ellipsizing
at a narrow container's edge, a mid-line pill relocating whole to the
next row at full row width, a pill held on the last clamped row
squeezing visibly into the remnant, and a short name in a wide
container rendering untruncated.
* Turtle/DrawText: keep tiny Fit max bounds from slicing or inverting clips
A line-remnant bound can resolve to nearly zero when the last permitted
row is already full where an inline widget's text begins. Two guards
keep that degenerate range within the whole-glyph truncation contract:
- The Fit max clamp in compute_final_size floors at zero, so a bound
smaller than the walk's margins cannot produce a negative width and
an inverted clip (a pill rendering as bare chrome with no title and
no ellipsis anywhere).
- DrawText treats a bound too narrow for the truncation ellipsis
itself as no bound at all: the layouter appends the ellipsis glyph
unconditionally, so a narrower clip would slice it open. Left
unbounded, the text overflows honestly, letting an enclosing flow's
inline-content clamp hide the widget and draw the ellipsis itself.
* macos: fix UI freezing after the window has been fully hidden
The present-gated frame pacing from commit `c0b851a57`
introduce a bug here, which basically causes vsync or other paint
events to never reach to UI after the macOS window is re-shown.
(moved to the foreground after having been bkgd'd for a while)
Fixes for this:
* Skip presenting while the window's `occlusionState` lacks
`NSWindowOcclusionStateVisible`, keeping the pass dirty so the first
visible beat repaints. This also avoids `nextDrawable` hard-blocking
the main thread (we set `allowsNextDrawableTimeout: NO`) on a pool
exhausted by presents the compositor will never consume.
* Add a 250ms watchdog to force the event handlers to start again
if they locked up somehow.
* On a watchdog timer, nudge `drawableSize` to rebuild the drawable pool
the same way a manual resize does, reclaiming stuck drawables so the
recovery present can't block on an exhausted pool.
* android: run script-VM gc after paint, like every other backend
commit `77cacb846` added the post-paint `gc()` sweep to the iOS, Windows, X11,
and Wayland event loops to match macOS, but Android never got one.
Now we do it on Android too.
* Windows: fix DPI-change problems: freezing, blank windows, drag-n-drop errors
- win32: do_callback queues re-entrant events (WM_DPICHANGED pumped inside
Present, nested WM_SIZE) instead of dropping them; the drops left dpi/geometry
stale, the window blank, and hit-testing offset after display-scale changes.
- win32: WM_DPICHANGED applies the OS-suggested rect and returns 0; removed the
duplicate-delivery compensations the lossless queue obsoletes.
- d3d11: no unbounded Present(1) block after a timed-out frame-latency wait
(DO_NOT_WAIT + paced retry); DXGI errors log instead of panicking.
- dnd: Drag/Drop events carry WindowId and are remapped via dpi_override_scale,
fixing drop-zone offset under UI zoom (Windows OLE, macOS external drops,
Wayland internal drags).
- win32: packaged-build window icons load at native shell sizes via LoadImageW.
* TextFlow: enforce `max_lines` for inline code wraps, list items, and inline widgets
`max_lines` + `text_overflow: Ellipsis` under-counted or skipped visual lines
whenever anything but a plain text run started one, so rich content (rooms-list
previews, anything with inline `<code>` or mention pills) could overflow its
line budget or hard-clip without an ellipsis.
* TextFlow: the inline `<code>` forced wrap left `is_continuation` stale, so a
wrapped code run was granted one extra row AND reported one fewer — a 2-line
overflow per wrap. The wrap now refreshes the continuation state and row
budget, is refused when no line remains, and its prediction probes measure
an unclamped layout (a clamped probe always claimed "fits on one line").
* Layouter: a non-wrapping layout truncated by `max_lines` never reported
`is_truncated` (detection was row-count only), so `max_lines: 1` labels
hard-clipped mid-word instead of showing "…". Width overflow on the
surviving last row now also triggers the ellipsis.
* TextFlow: list items charged two lines each (marker run + content run); the
marker now shares one visual line with the item's first content run.
* Inline child widgets (mention pills, images) bypassed the budget entirely:
Html now gates them on `is_content_truncated()`, charges the rows they open
via `track_inline_content()`, and holds a widget that lands on the last
allowed line in place (`Turtle::set_flow_wrap`) instead of letting the
turtle relocate it onto a row the budget can't pay for. A held widget that
still overruns the line is hidden behind a tracked clip and replaced with a
drawn "…" — the same contract text truncation has.
* TextFlow: fix row alignment and spacing for wrapped text beside inline widgets
On the single-batch text path (Windows/Linux), a multi-row text run never told
the turtle where its internal row boundaries were: every walk since the last
boundary — a bold sender on one visual row, a mention pill on the next — was
centered by `finish_row` against one merged row. Text sat a few px off the
pill baselines, rows around wrapped runs squeezed or overlapped, and an
up-centered pill on a padding-less first row rose above the clip and lost the
top of its rounded corners.
* DrawText: the single-walk path now allocates per visual row and runs
`turtle_new_line_with_spacing` at each internal boundary, keeping one glyph
instance batch. It emits a first-row walk owning the run's align entries and
a last-row walk with an empty range, so the run is never shifted twice.
* Turtle: `FinishedWalk` carries a `RowAlignRole`. A wrapped run's rows are
immovable (their glyphs share one batch): rows holding its visible text are
an `Anchor` — `finish_row_center` centers every shiftable walk on the
anchor's line, shifting a taller pill UP onto the text — while a
whitespace-only first row (a continuation that wrapped on a leading space)
is `Fixed`, so it cannot anchor a row to an invisible line. Anchor shifts
are clamped so no walk's top can rise above the turtle's clip.
* Layouter: `first_row_min_line_spacing_below_in_lpxs` is now honored (it was
stored and hashed but never read). Resumable draws pass the current row's
height plus wrap spacing plus the centering overhang, so a continuation's
second row lands where centered content beside it starts exactly one wrap
gap below the previous row — zero residual shift for uniform-height pills.
Selection capture and the `<code>` wrap probe pass the same floor so every
layout of a run shares one cache entry.
* Turtle: an anchored row returns its bottom forgiveness — the surplus its
allocation extends below the risen content — and `turtle_new_line`
subtracts it, keeping inter-row gaps uniform on both sides of anchored
rows. A turtle's final row keeps its full extent so nothing clips at the
bottom.
* uizoo: Html fixtures for line clamping and pill/text row alignment
Repro and regression fixtures mirroring a chat client's message surfaces:
max_lines clamping with inline <code> at several widths, atomic inline-widget
(pill) relocation/hold/ellipsis under a line budget, and timeline-style
pill+text rows at 9.3pt and 11pt metrics — including a CJK-titled pill and a
padding-0 first-row case that guards against clipped pill tops.
* Layouter: fill the last permitted row by grapheme when ellipsizing
When `max_rows` is set together with ellipsis truncation, word wrapping
would move a word that didn't fit off the final permitted row before
truncation ran, leaving that row ellipsized at the last word boundary
("@…") rather than at the last glyph that fits ("@quokka:…").
Word integrity is meaningless on a row that ends in an ellipsis, so lay
the final permitted row out by grapheme and let truncation cut at the
actual width limit.
* DrawText: account for walk margins when resolving a Fit max bound
The layout bound for a Fit-width walk with a relative max bound was the
raw resolved max, but the turtle's final width — and with it the clip
rect — gets clamped to that max minus the walk's outer margins. On top
of that, a Label passes the same margined walk to its inner DrawText,
re-applying those margins inside the turtle. Text laid out near the
bound therefore extended past the clamped clip, which sliced letters
off the end of untruncated text and cut the appended ellipsis down to
a single dot.
Subtract the walk's own horizontal margins from the resolved bound, and
when the enclosing turtle is itself an unresolved Fit with a max bound
(a Label sizing itself around this text), subtract that turtle's outer
margins too, so the layout bound matches the width the clip is actually
clamped to.
* uizoo: fixture for bounded pill labels ellipsizing at the width cap
Pill-like labels bounded to a fraction of the enclosing width, at
container widths that walk across the cap, plus one inline in an Html
flow. Truncation must always end in a visible trailing ellipsis, never
a bare mid-glyph cut.
* Layouter: keep word-boundary ellipsis on continuation rows
Grapheme-filling the last permitted row is wrong when that row is an
empty continuation: grapheme layout force-places a grapheme wider than
the row's remnant past the width limit with the truncation flag unset,
drawing overflowing text with no ellipsis. Finishing the row instead
truncates within bounds, so continuation rows keep the word-boundary
behavior.
* DrawText: scale the Fit max bound into layout units
The layouter works in unscaled units and row widths are multiplied by
font_scale on output, but the resolved Fit max bound was passed through
in physical units — the same mismatch max_layout_width_for_walk already
divides away. Any font_scale above 1 on a bounded label laid text out
past the clamped clip, reintroducing the sliced-tail bug the bound is
there to prevent.
* Turtle: add Base.Line, a Fit bound relative to the available line width
A static relative bound cannot express what an inline widget's inner
text actually has room for: that depends on the line the widget lands
on, its own leading geometry (icons, padding, spacing), and the
trailing insets after the text. Base.Line resolves a Fit max bound to
exactly that, with the enclosing line's flow selecting between two
measurements that are each final at the moment they are taken:
- A wrapping line can relocate the widget whole onto a fresh row, so
the bound is what a fresh row offers. Content sized to it either
fits where it is, or fits the row the widget is relocated to.
- A non-wrapping line — including one held non-wrapping by the
inline-content clamp on the last permitted row — keeps the widget
in place, so the bound is the remnant up to the line's right edge.
The measurement runs from the current turtle's content origin rather
than its pen, because a Fit max bound is evaluated twice: before the
content is laid out, and again in compute_final_size when the turtle
closes. A pen-relative measurement would collapse the closing clamp
to the leftover width and clip the content it just laid out.
* uizoo: fixtures for Base.Line-bounded pill labels
The four behaviors the line bound guarantees: a long name ellipsizing
at a narrow container's edge, a mid-line pill relocating whole to the
next row at full row width, a pill held on the last clamped row
squeezing visibly into the remnant, and a short name in a wide
container rendering untruncated.
* Turtle/DrawText: keep tiny Fit max bounds from slicing or inverting clips
A line-remnant bound can resolve to nearly zero when the last permitted
row is already full where an inline widget's text begins. Two guards
keep that degenerate range within the whole-glyph truncation contract:
- The Fit max clamp in compute_final_size floors at zero, so a bound
smaller than the walk's margins cannot produce a negative width and
an inverted clip (a pill rendering as bare chrome with no title and
no ellipsis anywhere).
- DrawText treats a bound too narrow for the truncation ellipsis
itself as no bound at all: the layouter appends the ellipsis glyph
unconditionally, so a narrower clip would slice it open. Left
unbounded, the text overflows honestly, letting an enclosing flow's
inline-content clamp hide the widget and draw the ellipsis itself.
* android oes video zero-copy and gles shader fixes
* regenerate windows-rs by windows-strip
* fix windows video freezes with MF on an MTA worker
* regenerate windows-rs by windows-strip
* MTA MF video, COM notify, YUV texture reuse
* add local video file playback support to video-player example
* NV12 Metal present, seek warm-up gate, YUV full range
* fix Linux GStreamer video: A/V mute, HLS prepare, async teardown
* no-op SelectVideoTrack/SelectAudioTrack on non-Linux backends
* Linux ALSA/Pulse: bigger buffers, fix resample setup, report device rate
* fix some warnings
* Move XInput/DirectInput device discovery off the UI thread
* fix GLSL unpack4u8 for GLES 3.0 (#version 300 es)
* Linux GStreamer: DMA-Buf NV12 OES zero-copy, optional GLMemory path
* Linux MediaPlugin: DMA-Buf NV12 and GLMemory zero-copy present APIs
* Add Windows SourceReader DXGI NV12 zero-copy video path
* fix build error
* fix GLES: add highp precision for sampler2DArray in Linux shaders
* playback speed support for android
---------
Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
Co-authored-by: jasonqiu <jasonqiu@futunn.com>
* Windows: fix DPI-change problems: freezing, blank windows, drag-n-drop errors
- win32: do_callback queues re-entrant events (WM_DPICHANGED pumped inside
Present, nested WM_SIZE) instead of dropping them; the drops left dpi/geometry
stale, the window blank, and hit-testing offset after display-scale changes.
- win32: WM_DPICHANGED applies the OS-suggested rect and returns 0; removed the
duplicate-delivery compensations the lossless queue obsoletes.
- d3d11: no unbounded Present(1) block after a timed-out frame-latency wait
(DO_NOT_WAIT + paced retry); DXGI errors log instead of panicking.
- dnd: Drag/Drop events carry WindowId and are remapped via dpi_override_scale,
fixing drop-zone offset under UI zoom (Windows OLE, macOS external drops,
Wayland internal drags).
- win32: packaged-build window icons load at native shell sizes via LoadImageW.
* TextFlow: enforce `max_lines` for inline code wraps, list items, and inline widgets
`max_lines` + `text_overflow: Ellipsis` under-counted or skipped visual lines
whenever anything but a plain text run started one, so rich content (rooms-list
previews, anything with inline `<code>` or mention pills) could overflow its
line budget or hard-clip without an ellipsis.
* TextFlow: the inline `<code>` forced wrap left `is_continuation` stale, so a
wrapped code run was granted one extra row AND reported one fewer — a 2-line
overflow per wrap. The wrap now refreshes the continuation state and row
budget, is refused when no line remains, and its prediction probes measure
an unclamped layout (a clamped probe always claimed "fits on one line").
* Layouter: a non-wrapping layout truncated by `max_lines` never reported
`is_truncated` (detection was row-count only), so `max_lines: 1` labels
hard-clipped mid-word instead of showing "…". Width overflow on the
surviving last row now also triggers the ellipsis.
* TextFlow: list items charged two lines each (marker run + content run); the
marker now shares one visual line with the item's first content run.
* Inline child widgets (mention pills, images) bypassed the budget entirely:
Html now gates them on `is_content_truncated()`, charges the rows they open
via `track_inline_content()`, and holds a widget that lands on the last
allowed line in place (`Turtle::set_flow_wrap`) instead of letting the
turtle relocate it onto a row the budget can't pay for. A held widget that
still overruns the line is hidden behind a tracked clip and replaced with a
drawn "…" — the same contract text truncation has.
* TextFlow: fix row alignment and spacing for wrapped text beside inline widgets
On the single-batch text path (Windows/Linux), a multi-row text run never told
the turtle where its internal row boundaries were: every walk since the last
boundary — a bold sender on one visual row, a mention pill on the next — was
centered by `finish_row` against one merged row. Text sat a few px off the
pill baselines, rows around wrapped runs squeezed or overlapped, and an
up-centered pill on a padding-less first row rose above the clip and lost the
top of its rounded corners.
* DrawText: the single-walk path now allocates per visual row and runs
`turtle_new_line_with_spacing` at each internal boundary, keeping one glyph
instance batch. It emits a first-row walk owning the run's align entries and
a last-row walk with an empty range, so the run is never shifted twice.
* Turtle: `FinishedWalk` carries a `RowAlignRole`. A wrapped run's rows are
immovable (their glyphs share one batch): rows holding its visible text are
an `Anchor` — `finish_row_center` centers every shiftable walk on the
anchor's line, shifting a taller pill UP onto the text — while a
whitespace-only first row (a continuation that wrapped on a leading space)
is `Fixed`, so it cannot anchor a row to an invisible line. Anchor shifts
are clamped so no walk's top can rise above the turtle's clip.
* Layouter: `first_row_min_line_spacing_below_in_lpxs` is now honored (it was
stored and hashed but never read). Resumable draws pass the current row's
height plus wrap spacing plus the centering overhang, so a continuation's
second row lands where centered content beside it starts exactly one wrap
gap below the previous row — zero residual shift for uniform-height pills.
Selection capture and the `<code>` wrap probe pass the same floor so every
layout of a run shares one cache entry.
* Turtle: an anchored row returns its bottom forgiveness — the surplus its
allocation extends below the risen content — and `turtle_new_line`
subtracts it, keeping inter-row gaps uniform on both sides of anchored
rows. A turtle's final row keeps its full extent so nothing clips at the
bottom.
* uizoo: Html fixtures for line clamping and pill/text row alignment
Repro and regression fixtures mirroring a chat client's message surfaces:
max_lines clamping with inline <code> at several widths, atomic inline-widget
(pill) relocation/hold/ellipsis under a line budget, and timeline-style
pill+text rows at 9.3pt and 11pt metrics — including a CJK-titled pill and a
padding-0 first-row case that guards against clipped pill tops.
* Layouter: fill the last permitted row by grapheme when ellipsizing
When `max_rows` is set together with ellipsis truncation, word wrapping
would move a word that didn't fit off the final permitted row before
truncation ran, leaving that row ellipsized at the last word boundary
("@…") rather than at the last glyph that fits ("@quokka:…").
Word integrity is meaningless on a row that ends in an ellipsis, so lay
the final permitted row out by grapheme and let truncation cut at the
actual width limit.
* DrawText: account for walk margins when resolving a Fit max bound
The layout bound for a Fit-width walk with a relative max bound was the
raw resolved max, but the turtle's final width — and with it the clip
rect — gets clamped to that max minus the walk's outer margins. On top
of that, a Label passes the same margined walk to its inner DrawText,
re-applying those margins inside the turtle. Text laid out near the
bound therefore extended past the clamped clip, which sliced letters
off the end of untruncated text and cut the appended ellipsis down to
a single dot.
Subtract the walk's own horizontal margins from the resolved bound, and
when the enclosing turtle is itself an unresolved Fit with a max bound
(a Label sizing itself around this text), subtract that turtle's outer
margins too, so the layout bound matches the width the clip is actually
clamped to.
* uizoo: fixture for bounded pill labels ellipsizing at the width cap
Pill-like labels bounded to a fraction of the enclosing width, at
container widths that walk across the cap, plus one inline in an Html
flow. Truncation must always end in a visible trailing ellipsis, never
a bare mid-glyph cut.
The Feb websocket migration left windows_stdin.rs behind: hosted apps
got no Event::Timer at all (StartTimer/StopTimer fell into the catch-all,
nothing dispatched PollTimers), no HttpRequest/CancelHttpRequest, a
WindowGeomChange that never fired the event or the pass redraw, and an
unguarded stdin_windows index in the repaint path. Ported straight from
macos_stdin.rs / linux_x11_stdin.rs; compile-checked against
x86_64-pc-windows-msvc, still needs a live run on a Windows box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The directive was that the demo scene become a project template you can make
new worlds from and edit by voice. This is the blocker gone: an authored
splash file can now build the world the demo builds.
`apps/arcade/resources/template.splash` is that world — sky, sun, terrain,
crossroads, two rows of houses, six distinct drivable cars, a bearded hero,
wandering townsfolk and the spiral climb — entirely in `game.*` verbs. It
loads: "eval ok, 28 entities, 5 skinned characters".
Two engine gaps had to close first, and both were the same shape: arcade
owned something the script could not reach.
**The script could not see the asset library.** `ScriptHost::set_assets`
existed and arcade never called it, so every asset verb — find_model, model,
kits, cast — reported "no stock library on this device" on a machine with
4,700 models on disk. arcade built an index for its own Rust demo and kept
it. An authored game therefore could not use any of the art the demo is made
of, which is most of the reason the demo had to stay in Rust. The index is
now built once, lazily, and shared with the host.
**Script characters had no bodies.** The skinned draw path walks
`self.villagers`, which only the built-in Rust world ever filled, so every
character in an authored game rendered as its bare collision box — which is
what a splash world looked like next to the demo. `sync_script_characters`
now builds that list from `blocks.characters` after each eval, matching the
model id the script asked for against the loaded cast and round-robining on a
miss. A wrong-looking character beats an invisible one.
Not yet done, and the template is honest about which parts are which: the
climb's moving lifts and the fireworks still have no verb, and the world is
written out longhand where the Rust version used loops.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for the demo becoming a splash TEMPLATE. Two things the script
surface could not express, so the demo had to stay in Rust.
**`game.terrain` carried its own single-octave value noise.** That meant the
terrain an AI could reach from splash was strictly worse than the terrain the
engine could make, and the two drifted independently — every fix to
`libs/game/gen/terrain.rs` (fBm, domain warp, world-unit frequency,
slope-aware colour, rim relief) was invisible to any authored game. It now
calls that generator. One generator, one set of bugs.
New params exposed: `feature` (distance between hills, in world units —
answerable, unlike "what is a good freq"), `octaves`, `warp`, `ridged`,
`flatten`, `rim`/`rim_start`.
Two compatibility decisions worth stating:
- `freq` still works. It meant cycles per CELL INDEX, so its wavelength in
world units is span/((cells-1)*freq); translating rather than ignoring it
keeps an existing world looking like itself.
- `step` now defaults to 0, not 1.0. The old default quantised every smooth
slope into one-unit stairs, so a script asking for smooth terrain got a
contour map. Scripts that want terraces still ask for them.
**`Car` now carries a model**, as `Character` already did, and `game.car`
takes `{model}`. Without it a host could only ever draw ONE kind of car —
which is exactly why the arcade fleet had to live in Rust, and why a
splash-authored world could not have more than a single vehicle shape.
The renderer prefers the car block's own model and falls back to walking the
`parked_car` role, so the same code path serves an authored game and the
built-in demo without the script needing to know the fallback exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Every car drives.** Six vehicles — ambulance, delivery, police, sedan,
taxi, delivery-flat — each a real chassis with a Car block and its own model,
so any can be walked up to, got into and driven away.
The logic is shared by construction rather than by copying, which is why one
car became six with no new plumbing: the interact prompt is DERIVED from the
presence of a car block, the mount is derived from the prompt, and the
renderer now walks every entity tagged "car" instead of finding the first.
Two asset-query lessons, both silent failures:
- `Spread::Variants` means "all from ONE family". Right for a terrace of
houses, exactly wrong for cars — it returned six ambulances. `Spread::Kinds`
caps at one model per family, which is what "one of each type" means. The
query also has to name what the models are CALLED: "car vehicle sedan truck
van" matched a single model.
- Asking for more house variants than the kit has houses returns LOT pieces.
`building-type-o` is a swimming pool, and it duly appeared on the street at
house height. A pool is a valid model that scales like any other, so
nothing warns you.
The chassis now spawns `hidden` rather than being hidden later once a model
loads. That ordering made "draw a red slab inside the car" the default and
correctness an afterthought — with one car the afterthought fired, with six
it hid the first and left five driving inside red boxes.
That is a workaround, not a fix: `hidden` exists because an Entity is both a
physics body and a renderable, so every collision shape must opt OUT of being
drawn. A body should not be renderable at all.
The climb replaces the linear jump course: nine ledges spiralling a solid
tower, two timing platforms, an orbiting lift, a summit. Rise per ledge stays
inside one jump, the route is visible from the bottom, and falling costs
height rather than a life.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"They explode too big for the number of particles." Exactly right, and the
mistake was treating star count and break size as two knobs. They are one.
A sphere is filled by rays per steradian, so doubling the radius needs FOUR
times the stars to read equally dense. The previous commit sized the break
honestly — 30-52 m, a real 6in shell — while leaving it at 64 stars, and 64
rays spread over 40 metres is not a flower, it is a handful of unrelated
dots drifting apart.
So: 2560 beads per shell at 8 per trail = 320 stars, up from 64, and the
break pulled in to 17-28 m (a 3in shell) from 30-52. Density is the product
of both changes — five times the rays into a third of the volume.
Beads are also slightly larger (1.25 from 0.9), because a bead 60+ metres
away has to survive being a couple of pixels.
Cost is 92k triangles at the 18-shell peak, which is the honest price of
density and exactly the sort of thing the thermometer exists to cut on a
headset. Still one instance per shell on the CPU.
A test pins SPARKS_PER_SHELL / TRAIL_LEN to the star count the shader
hardcodes. Drift there does not fail loudly — the Fibonacci distribution
just covers the wrong fraction of the sphere and the break stops being
round, which looks like a tuning problem and is not one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"They don't fall this quickly." Correct, and the model was wrong rather than
mistuned: the fall term was 0.5*g*t^2, free-fall, as if each star were a
dropped rock. A star is a few grams of burning composition with a lot of
drag, so it reaches terminal velocity almost immediately and then DRIFTS.
Vertical motion is now quadratic for the first instant and a constant ~7 m/s
descent after — which is the hang every display has and ours did not.
Sized against reality, which our world happens to make easy: one unit is one
metre here (a character is 1.8 tall, houses 6-8). Real stars leave the burst
charge at 50-100 m/s and drag stops them in about a second, so the break
opens to speed/k across. At k = 3.08 the new 48-80 m/s gives a 30-52 m
diameter shell — a 3in to 6in break, what a town display actually fires. It
was 11-17 m before, which is why it read as a firecracker. Bursts moved up to
38-58 m accordingly; a real 3in reaches ~80 m, but ours stay lower so they
sit inside a camera that is pitched down at a street.
Shells are no longer all the same. A third are DUAL-COLOUR breaks, where half
the stars carry the second colour from the start rather than merely cooling
into it — the two-tone shell in every display photo. The split is per star and
stable, so a ray keeps its colour all the way out instead of shimmering. A
sixth are WILLOWS: stars thrown at half speed with a heavier drift, so they
arc over and trail down. The rest are plain peonies.
Style is chosen on the CPU, one float per shell, and applied entirely in the
splash-side `spark_color` — the engine still knows nothing about how any of
this looks.
Also dropped the leading-edge brightening: with uniform star speed there is no
longer a fast outer shell to distinguish, so it was tinting at random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nineteen plan, worklog and handoff documents were tracked at the top level.
They are scratch for whoever is mid-task: they go stale the moment the work
lands, and a root full of them buries the handful of docs that are actually
reference material.
Untracked, not deleted — they stay on disk and in history. Several are live
plans somebody is still working from (route.md, shiny.md, map.md, bridge.md,
datasources.md, layers.md), and deleting a plan to tidy a directory listing
is a bad trade. .gitignore keeps them from drifting back.
Kept tracked, beyond the three asked for:
- AGENTS.md is not chatter. It is the Studio remote runbook — the bridge
protocol, the launch flow, the "don't do this" list — and it is what an
agent working in this repo reads before touching anything. Removing it
would be self-defeating.
- talk.md is a conference talk, not a handoff.
game.md goes too, which is what it always wanted: it was asked to stay out of
the repo when it was written and had been tracked by accident since.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Fireworks are streaks of PARTICLES, not a sphere expanded set of rects" —
and the reference photos show it plainly: every ray is beaded, a string of
glowing points strung along the path its star has flown.
So the model changed rather than the tuning. Each star is now a TRAIN of 8
beads, and a bead is simply that star's own closed form evaluated 40ms
earlier. Nothing extra is simulated and nothing extra is uploaded — the
trajectory was always a function of time, so sampling it at t - delay is free.
512 beads per shell is 64 stars with a real trail each.
Beads taper and dim toward the tail, so a ray has a bright head fading back
toward the burst centre, which is the shape every photograph shows.
I had built this as a stretched quad first — elongating the billboard along
the screen projection of the velocity. It is the standard trick and it is
wrong here: it draws one long rect per star, so the rays are smooth bars
rather than beaded, and a rect wide enough to see is also wide enough to look
like a slug. Removed.
The sprite is a round, ANTIALIASED dot. `smoothstep` rather than a linear
ramp, because a hard cutoff shows the rasteriser's stair edge on something
this small and bright, which is exactly where aliasing is most visible. Both
falloff terms reach zero at 0.8 of the half-width — inside the corners as well
as the edges — so the billboard border can never cut the dot. That was why
every spark read as a filled square.
Sound removed entirely, including the synth preset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Still a bit too random." It was, and the fix corrects something I took from
the wrong source last time.
Looked up how actual shells are built. Stars are packed EVENLY around the
burst charge and lit at the same instant, so they all leave with the same
force — that even spacing is precisely why a peony reads as round from every
angle. Two changes follow:
- Directions come from a FIBONACCI SPHERE instead of a per-spark hash. The
golden angle steps phi so successive stars never line up, and z steps
linearly so they spread evenly in AREA rather than in latitude (which
bunches them at the poles). Hashed directions give clumps and holes, and no
amount of extra sparks makes that look like anything but noise. A per-shell
rotation keeps two shells from being the same object twice.
- Speed is near-uniform (6% jitter) instead of a 4:1 spread. I took that
spread from the canvas demos last commit — but random(1,10) is a 2D trick
for filling a disc. In 3D, identical stars igniting together travel
together, and the spread just turns the sphere to mush.
The bang was also wrong: 900Hz of broadband noise at 0.34 gain is a shotgun
in a small room. A shell is heard from far away, so it arrives mostly low and
quiet — now a 220->38Hz thump at 0.085, falling away over 1.1s.
Sources: epicfireworks.com "The Art and Science of the Chrysanthemum Firework
Effect", liuyangfireworks.net "Ball Shell vs. Cylinder Shell"
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "too squiggly — real fireworks are more symmetric, they don't
move in big waves". They were right, and the cause was mine: the arcade
style's `spark_motion` added a curl and a jitter, so every spark travelled a
visible wave.
Read how the canvas demos actually do it (CreativeJS, the codepen/thecodeplayer
lineage). The answer is that they apply NO positional noise at all: one
uniform radial angle per spark, then nothing but friction and gravity. All
the shape comes from the SPEED spread, not from moving sparks around. Three
changes follow from that:
- `spark_motion` returns zero. The hook stays, because it is the right seam
for a style that wants to be strange — a spiral shell, a jellyfish — but
the default is symmetric.
- Drag matched to the convention: `speed *= 0.95` every frame at 60fps is
exactly e^(-kt) with k = -60*ln(0.95) = 3.08, replacing a softer constant
I had guessed.
- A 4:1 speed spread instead of 1.8:1. The demos use random(1,10); a narrow
spread leaves a hollow shell with nothing in the middle.
Also reported: too fast and too far. Shells now live 3.2-4.6s (was 1.5-2.4),
throw sparks 17-27 units (was 11-19), and burst in a 25-46 unit annulus (was
out to 68).
The sprite is fully contained inside its quad. Its falloff dies at 0.8 of the
half-width, inside the corners as well as the edges, so the billboard's
straight edge can never cut the glow — which showed as square-clipped sparks.
The old cross-flare ran to the border and was the worst offender, so it is
gone. Colour is emitted unpremultiplied with zero alpha: pure additive light
under premultiplied blending, so sparks add and never occlude.
And they bang now. A shell reports its burst point exactly once, when its age
crosses zero, and the host plays a broadband noise burst there — positioned,
so it pans and attenuates like any other world sound. Noise rather than a
tone because a shell is broadband; a tone reads as a laser.
Sources: creativejs.com/tutorials/creating-fireworks,
thecodeplayer.com/walkthrough/canvas-fireworks-tutorial
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They work. The bug was structural, not tuning: `DrawGameFirework` derefed
`DrawCube`, which brings ITS instance fields along, so the fields appended
after them sat at offsets the script-side layout never accounted for. Every
instance value read back garbage — the burst rendered at the world origin,
and the spark size ignored whatever Rust wrote, which is why scaling it 25x
changed nothing on screen.
What settled it was making the GPU report what it actually saw: encode the
instance values as colour on a fixed clip-space quad and read them back off
the framebuffer. The decoded numbers CHANGED WHEN THE CAMERA ROTATED.
Instance data cannot depend on the view, so the shader was reading view
memory. That one observation killed every "too big / too bright / too close /
wrong units" theory at once — they were all downstream of data that was never
arriving.
The fix is to follow `DrawGameShadow`, the one shader here that instances
correctly: deref `DrawVars` and declare the uniform buffers, vertex buffer
and varyings explicitly, so the instance fields are the only ones and the
layout is unambiguous. Drawn with `cx.add_instance` per shell, like it.
Tuned from what it looks like in motion: closer (annulus 37-68 units rather
than out to 120) and less sporadic (a shell every 0.2-0.6s, up to 18 alive).
Enabled by default now that it is worth seeing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Encoding the instance values as colour on a fixed clip-space quad and
reading them back off the framebuffer settles what was guesswork:
1. Scaling the spark size 25x in Rust changes nothing on screen.
2. The decoded values CHANGE WHEN THE CAMERA ROTATES.
Instance data cannot depend on the view, so the shader is not reading this
struct at all — the fields are bound at the wrong offset. That rules out
every 'wrong value' theory and points at the DrawVars::as_slice() pointer
trick and what sits at DrawCube's tail. DrawGameSky appends to DrawCube the
same way and works, so the delta between those two is the answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CPU launches shells; the GPU animates every spark from a closed form. Once
a shell bursts nothing in the world can influence a spark — it flies from a
known point, at a known time, along a fixed arc — so position is a function
of (spark index, seed, age) and there is no state to step and nothing to
upload per frame. Twelve shells of 320 sparks is 3,840 particles for TWELVE
instances of CPU work, against 3,840 simulation updates and a 3,840-instance
upload for the stepped path.
**The style seam is the point.** Rust owns structure — trajectory, lifetime,
billboarding — and exposes four hooks a splash script overrides by
inheritance: `spark_motion` (swirl, fizzle, drift), `spark_size`,
`spark_color`, and `spark_pixel` (the sprite program). Arcade's own styling
lives in its `script_mod!`, not in Rust: a three-stage temperature burn,
hotter on the fast outer shell than the slow core, with sparse glitter
strobing. A generated game can restyle the sky without being able to reach
the simulation.
Two real bugs fixed on the way:
- **Vec3f instance fields silently misalign.** A `Vec3f` is tightly packed in
Rust but a `vec3` obeys 16-byte alignment in the shader ABI, so the burst
origin read back as zero and every shell rendered at the world origin, on
the ground. The other shaders here get away with `Vec3f` because theirs are
`uniform`, not instance. Everything is packed into `vec4`s now, which is
the shape the hardware wants anyway and removes the class of bug.
- **Eight bare `panic!()`s in the shader backend** now name the backend, the
stage and the IO type, and the commonest case — reading a geometry
attribute from `pixel:` — gets a message saying so and showing the varying
that fixes it. Previously a shader that tripped it aborted with no text at
all, which is not a barrier, it is a wall in the dark.
**Gated off by default.** The spark SIZE instance is not reaching the shader:
scaling it 25x changes nothing on screen, so every spark draws as a
screen-filling blob and the sky whites out. That is the same class as the
Vec3f bug above and I have not found the second instance of it. The launcher,
the trajectory, the placement annulus and the whole hook surface are finished
and tested; this is one plumbing bug from working. ARCADE_FIREWORKS=1 turns
it on to work on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The ramp was a solid cube.** Reported as "i dont seem to be able to walk
the character up the slanted block towards the platform" — and the wedge
had no collision handling anywhere. Movers sweep against static AABBs, so a
`Shape::Wedge` was collided as the box that CONTAINS it: the ramp built to
be walked and driven up presented a vertical wall at its low edge, and you
stopped dead against nothing you could see.
Wedges are now surfaces rather than walls, handled exactly the way terrain
already was — the symmetry is the point, since terrain had solved this
problem years earlier in the same file. They are excluded from the axis
sweeps, and a ramp floor pass sits underneath: walk up where the slope
rises less than CLIMB, blocked where it rises faster. That falls out
correctly at both ends without special-casing either — the gentle slope is
walkable, and the wedge's full-height back face is still a wall, because
there the surface jumps well past CLIMB in one step.
Sampled across the mover's whole footprint, not just its centre, so
standing with half your feet on a ramp stands you on the ramp.
**Conforming statics to terrain now ADDS the ground height instead of
replacing it.** Replacing looks equivalent, because everything is authored
resting on flat ground — right up until something is deliberately in the
air, at which point it flattens every platform, buried base and raised
ledge onto the dirt, and the failure reads as the level's fault rather than
the function's. Adding is a no-op on flat ground and rides the slope
elsewhere. Found by adding a jump course whose heights it ate.
**A crossroads and a side street.** One straight road reads as a corridor;
a junction is the smallest thing that makes a place feel like it has
somewhere else to be. The side street runs out to the yard, so the physics
corner is somewhere you drive TO rather than somewhere that is merely
nearby.
**A jump course**: a static step to read the route from, a platform that
slides across your path, one that rises and falls, and a wide still ledge
that is obviously the end. Gaps are sized against the controller's actual
jump distance rather than eyeballed, and the two movers run on different
periods so they drift in and out of phase instead of presenting the same
crossing every lap. The ramp's high edge is the run-up, which is what turns
two separate toys into one thing to do.
Three tests, one per claim: a character walks up, the back face still
stops them, and standing on the slope reports on_floor (without which the
controller silently refuses to jump). The first version of the walking test
passed for the wrong reason — its world had no ground, so the walker fell
past the ramp and met it from BELOW, where being blocked is correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**A guy with a beard.** The player wears the barbarian, pinned BY NAME
rather than by cast index — the cast is assembled from whatever the asset
library contains, so an index silently becomes a different person the
moment a pack is added, and that failure reads as a cosmetic surprise
rather than a bug.
Kenney was the obvious cheaper choice and it does not work: every character
GLB in both Kenney packs has only root/torso/head/arm/leg parts, and their
colormap is flat colour swatches with no painted faces, so there is no
bearded Kenney character to pick. Verified by rendering the heads rather
than by guessing from filenames.
**The sun is fixed.** It swept a full day every 40 seconds, which looked
lively for about ten seconds and then just cost money: AO and cast shadows
are baked, and the baker rebakes whenever the sun crosses
`sun_rebake_angle`, so nothing on screen ever settled. Now one explicit
direction at 38 degrees elevation — the engine default sits at 54, nearly
overhead, where shadows barely clear their own footprint and nothing reads
as standing on anything. At 38 a shadow runs about 1.3x its caster's
height: long enough to describe the shape and show the ground's slope,
short enough that the village does not vanish into its own shade. The day
cycle survives behind ARCADE_DAYCYCLE, where it belongs until rebaking is
incremental.
**The simulated wheels now sit under the drawn ones.** Reported as the car
"not really following the landscape, wheels not really touching". The
config's track and wheelbase are authored for a generic chassis while the
mesh is scaled from whatever the pack shipped: for the stock truck the
drawn wheels sit 1.30x wider and 1.39x further apart than the simulated
ones, with a 1.33x radius. On flat ground that is invisible, which is
exactly why it survived a flat-ground test measuring a 3e-6 residual — the
model's lowest point still lands on the road. On a slope it is not
invisible: the body pitches and rolls about the SIMULATED contact points,
and a drawn wheel further out swings through a bigger arc, so it lifts off
the ground. Adding terrain is what made it visible.
Fitted from measured bounds at model-load time, the same discipline as
sitting the mesh on measured bounds rather than on the collision box.
I first read "floaty" as suspension bounce and stiffened the springs. That
was the wrong problem; reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.makepad/` is per-checkout runtime scratch — terminal scrollback, AI chat
logs, window layout. It was tracked by mistake and has now been removed from
history entirely; this stops it coming back.
It should never have been committed: it churns every session (so it makes
every pull a conflict — which is exactly what it did, blocking a merge on a
fresh checkout), it had grown to ~30 MB, and it carried developer email
addresses and absolute home paths into a public repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "the camera joystick feels y inverted", and it was.
A pad reports the stick's UP as +y. `look_dy` is in SCREEN units, where y
grows downward and `+look_dy` raises the camera and looks down. The stick
value went in unnegated, so pushing the stick away from you looked down
while pushing the mouse away from you looked up — two devices disagreeing
about which way is up, on the same camera.
The test states the rule as agreement between the devices rather than as a
raw sign. A sign assertion is satisfiable by flipping whichever end of the
chain you happened to be reading, and this chain has three sign conventions
in it (pad, screen, render pitch); what actually has to hold is that the two
physical "push away from me" gestures do the same thing.
`ARCADE_INVERT_Y` still restores the old behaviour, which is now what it
always should have been: a preference, not the default by accident.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The flat plane is gone.** Terrain existed all along — heightfield collision
via box3d, AO and shadow raymarching against it, a mesh path, a
`game.terrain` verb — and arcade simply spawned a flat slab instead. That is
the fourth capability this week that was built and never called.
Turning it on was not enough, because the generator had faults that a
screenshot explains faster than prose:
- **Single-octave value noise.** Detail at exactly one scale reads as melted
blobs. Now fBm with domain warp, which is the single highest-value knob
here: warping the sample point bends contours into ridges and valleys
instead of round lumps on a visible grid.
- **`step` defaulted to 1.0**, quantising every smooth slope into 1-unit
stairs. The old default renders as a literal contour map. Off by default.
- **Noise was indexed by CELL INDEX, not world position**, so asking for a
finer mesh silently generated a different landscape. Resolution should buy
detail, never a new world.
- **Colour came from height alone**, which paints terrain in horizontal
stripes like a contour map. Now height AND slope, so rock lands on cliff
faces and grass on the shelf above them.
Generation moved to `libs/game/gen/terrain.rs` as a pure function. Beyond
testability that was forced: arcade's demo world has no script VM, so the
only generator in the tree was one it could not reach.
`rim_relief` is the load-bearing idea. Terrain interesting everywhere is
terrain you cannot put a town on; terrain flat enough to build on is a green
table. Growing the relief outward gives a playable basin ringed by something
worth looking at, and doubles as a soft boundary. It SCALES the noise rather
than adding a radial ramp — the ramp version has no noise in it and renders
as a smooth machined ring between two flat plains, which I built first and
threw away after looking at it.
Two things the tests taught me rather than confirmed:
- Normalising fBm by the sum of octave amplitudes — the textbook form — makes
five octaves come out FLATTER than one, because summing decorrelated fields
concentrates them about the mean. Normalising against the field's own
extents makes `amp` mean literal relief at any octave count.
- The octave test measures CURVATURE, not slope. At gain 0.5 / lacunarity 2
every octave contributes equally to slope — that is what self-similar
means — so a slope-based test reports no difference while the terrain
visibly gains detail. My first version of that test was wrong, not the code.
Statics get one conform pass after composition; movers already clamp to the
terrain every tick in `step.rs`, so the player, villagers and car find the
ground themselves.
**Cars now touch the road.** Kenney authors vehicles origin-at-the-contact-
patch — tyres exactly on y=0, with each wheel node lifted by its own radius —
and every vehicle in every kit measures min.y == 0, verified across all 4442
GLBs in the library. We were dropping the model by `half.y` instead, a rule
that is right for a walker (a Mover's box bottom really is its feet) and
wrong for a raycast vehicle, whose suspension probes from the chassis origin.
The float was suspension travel plus wheel radius, 0.341 units, predicted in
closed form and matched by simulation to 1e-5. The same line also shifted in
world Y after rotation, so the mesh slid out from under a leaning chassis.
Both now derive from measured bounds along the body's own down axis — no
constant anywhere. Note the convention is real but NOT universal: track and
road pieces go to min.y = -1.0, so it must be read, never assumed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- win32: do_callback queues re-entrant events (WM_DPICHANGED pumped inside
Present, nested WM_SIZE) instead of dropping them; the drops left dpi/geometry
stale, the window blank, and hit-testing offset after display-scale changes.
- win32: WM_DPICHANGED applies the OS-suggested rect and returns 0; removed the
duplicate-delivery compensations the lossless queue obsoletes.
- d3d11: no unbounded Present(1) block after a timed-out frame-latency wait
(DO_NOT_WAIT + paced retry); DXGI errors log instead of panicking.
- dnd: Drag/Drop events carry WindowId and are remapped via dpi_override_scale,
fixing drop-zone offset under UI zoom (Windows OLE, macOS external drops,
Wayland internal drags).
- win32: packaged-build window icons load at native shell sizes via LoadImageW.
**The HUD was never drawn.** `libs/game/render::hud` has existed and
gamemaker has called it all along; arcade simply never did. So the
interact prompt was computed every tick, published to `world.hud_slots`,
and thrown away — the get-in-a-car mechanic was discoverable only by
guessing the key. Same shape as the gamepad never being polled: the
capability was there and the app never called it.
Two things found while wiring it:
- The prompt asked for `size: 1.0`, which reads like a scale but is an
absolute point size to the renderer — any value above zero is taken
literally. It would have drawn a one-point speck. Now 17.0, deliberately
above the 12.0 default, since this is the one line a player has to
notice without being told to look.
- Added a standing controls hint. Nobody sits a child down with a manual,
and the affordance prompt only appears once you are already next to
something; this is what tells you how to get there.
**Arcade could not build headless.** The gamepad poll was added without
the `cfg(headless)` split gamemaker uses, and arcade had no `build.rs` to
define the cfg at all, so `MAKEPAD=headless` failed to compile and the
render-to-PNG path went with it.
**The walking camera sat too close** — at 6.5m the character filled a
fifth of the frame and hid the world behind them. Now 9.0m.
That broke a test asserting `car.distance > on_foot * 1.5`, and the fix
is the test, not the number. That ratio read like a decision but was
really the quotient of two values that happened to be current, so
correcting the walking boom on its own merits broke an assertion about
driving while nothing about driving had changed. Restated as a margin:
what has to hold is that getting into a car visibly widens the view, and
several metres does that at any walking distance.
Also logs gamepad connect/disconnect on change, so "the pad does nothing"
is answerable from a release log — either the app never saw the device or
it saw it and the mapping is at fault.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A game was playable standalone and completely dead under Studio — which is
exactly where it gets developed. The cause is structural, not a binding: an
app hosted by Studio is a child process with no window of its own, so the
OS hands controller input to Studio and never to it. On macOS the child
never even reaches `init_cx_os`, so `apple_game_input` is None and
`game_input_states()` returns an empty slice forever.
So controllers now travel the same road mouse and key events already do.
`StudioToApp::GameInput` carries the whole controller set; the run view
polls its own `game_input_states` on the tick it already uses to batch
messages, and the app lands them in `Cx::game_input_remote`, which
`game_input_states()` reads whenever `in_makepad_studio` is set.
Details worth knowing:
- Sent on change only. A pad at rest reports identical level state every
tick, and that is not worth the wire. The memo drops on focus loss so
regaining focus resends rather than assuming the app still holds it.
- Only the focused run view forwards, so two open run views cannot both be
driven by one stick. That is the rule the keyboard already follows.
- The set is replaced wholesale rather than merged, so a pad that unplugs
disappears by being absent instead of freezing at its last state.
- Wheels ride along with gamepads. Forwarding only pads would have left a
wheel silently dead under Studio while a pad worked, which is a worse
failure than both being dead.
- Linux gains controllers under Studio that it does not have standalone:
its native backend is a stub, but the forwarded path is platform-neutral.
The 21-field mapping lives in ONE place with both directions together.
Written per-side it would have drifted the first time a button was added —
one end gaining a field and the other quietly reporting zero for it. The
round-trip tests give every field a distinct value, because a fixture of
uniform numbers cannot detect two crossed wires, and the sticks are pinned
separately since flattening a Vec2 to scalars is where a swap would hide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>