Repository navigation
feat: Android build generation (#133) - #3
Conversation
…build_files (#133) Add Android platform support to the assembler: - AndroidConfig struct (package_name, min/target SDK, orientation) - build_zig.txt template sections: header_android (NDK detection), android_deps, backend_sokol_android, android_exe_start (shared lib), android_link (libandroid, liblog, libGLESv3, libEGL), android_footer - build_files.zig Android branches mirroring iOS pattern Key difference from iOS: output is a shared library (.so) for NativeActivity, not an executable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR SummaryMedium Risk Overview The generator now emits Android-specific template sections that detect the NDK sysroot, select device vs emulator targets, build a Reviewed by Cursor Bugbot for commit a94ff14. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Code Review
This pull request introduces Android support to the build system by adding an AndroidConfig structure and implementing the corresponding generation logic in build_files.zig. The update includes a comprehensive template for Android builds that handles NDK detection, target resolution for both physical devices and emulators, and linking for the Sokol backend and Android system libraries. Feedback focuses on making the build template more robust by removing hardcoded architecture paths and API levels, which currently prevent emulator builds and ignore user configuration. Additionally, the NDK version detection logic should be improved to ensure the highest version is selected, and several redundant memory allocations and duplicated helper functions within the template should be cleaned up.
| .os_tag = .linux, | ||
| .abi = .android, | ||
| }); | ||
|
|
There was a problem hiding this comment.
To support both physical devices and emulators, the architecture-specific NDK paths should be derived from the target. Hardcoding aarch64-linux-android in the include and library paths (lines 481 and 513) will break the build when emulator_mode is enabled.
const ndk_arch = if (emulator_mode) "x86_64-linux-android" else "aarch64-linux-android";
|
|
||
| // Configure NDK sysroot paths on sokol_clib for C header compilation | ||
| sokol_clib.root_module.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ ndk_sysroot, "usr/include" }) }); | ||
| sokol_clib.root_module.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ ndk_sysroot, "usr/include/aarch64-linux-android" }) }); |
| } | ||
|
|
||
| try tpl.writeSection(build_zig_tmpl, "android_exe_end", w); | ||
| try tpl.writeSection(build_zig_tmpl, "android_link", w); |
There was a problem hiding this comment.
The AndroidConfig values (like target_sdk_version) are currently ignored during generation. The API level is hardcoded to 34 in the template. Consider using renderSection to pass the configured API level to the template.
const android_cfg = cfg.android orelse config.AndroidConfig{};
try tpl.renderSection(build_zig_tmpl, "android_link", .{ .target_sdk = android_cfg.target_sdk_version }, w);
| .android_link | ||
| lib.linkLibrary(sokol_clib); | ||
| lib.linkLibC(); | ||
| lib.root_module.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ ndk_sysroot, "usr/lib/aarch64-linux-android/34" }) }); |
| defer b.allocator.free(ndk_home); | ||
| const sysroot = b.pathJoin(&.{ ndk_home, "toolchains", "llvm", "prebuilt", ndkHostTag(), "sysroot" }); | ||
| if (std.fs.cwd().access(sysroot, .{})) |_| { | ||
| return b.allocator.dupe(u8, sysroot) catch null; |
| var iter = dir.iterate(); | ||
| while (iter.next() catch null) |entry| { | ||
| if (entry.kind == .directory) { | ||
| if (latest) |prev| b.allocator.free(prev); | ||
| latest = b.allocator.dupe(u8, entry.name) catch null; | ||
| } | ||
| } |
There was a problem hiding this comment.
The logic for finding the 'latest' NDK version is fragile because dir.iterate() does not guarantee any specific order. It will simply return the last directory encountered during iteration, which might not be the highest version. Consider using a basic string comparison or std.SemanticVersion to ensure the highest version number is selected.
| defer b.allocator.free(version); | ||
| const sysroot = b.pathJoin(&.{ ndk_dir, version, "toolchains", "llvm", "prebuilt", ndkHostTag(), "sysroot" }); | ||
| if (std.fs.cwd().access(sysroot, .{})) |_| { | ||
| return b.allocator.dupe(u8, sysroot) catch null; |
| fn overrideImport(m: *std.Build.Module, name: []const u8, module: *std.Build.Module) void { | ||
| const gop = m.import_table.getOrPut(m.owner.allocator, name) catch @panic("OOM"); | ||
| if (!gop.found_existing) { | ||
| gop.key_ptr.* = name; | ||
| } | ||
| gop.value_ptr.* = module; | ||
| } |
There was a problem hiding this comment.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f36cdfc. Configure here.
…s into GameConfig (RFC-PLUGIN-EVENTS #3) Phase 3 of RFC-PLUGIN-EVENTS (flow-codegen#11). Phase 1 (#174, 602aebd) shipped `pub const PluginEvents` in the generated `main.zig`, but flow files at `scripts/flows/*.zig` only `@import("game")` (the `game.zig` shim re-exporting `Game` / `EntityId` from labelle-engine — assembler#116) and cannot reach `PluginEvents` from there. New-form `OnEvent` flow codegen needs to reflect payload signatures against `@FieldType(PluginEvents, "<tag>")` at the flow file's emission site, so the resolver must be reachable through the same `@import("game")`. Phase 2 (labelle-box2d 6c44691) emits qualified-tag events through `game.emit(.{ .box2d__collision_begin = .{ ... } })`. The engine's `game.emit(event: GameEvents)` accepts a single union, so this fails to compile against the `GameEvents = void` the assembler used to pass for projects with no game-side events. Two additive changes here, both gated on `cfg.plugins.len > 0` (and so no-ops for unaffected projects): **1. Shim re-export** (`src/root.zig` — option (a) from the phase-1 ticket comment): - `game_shim_source` constant becomes `generateGameShim(allocator, cfg)`. - When plugins are declared the shim appends the same `pub const PluginEvents = blk: {...}` block `main.zig` already gets — exact source reuse via the now-pub `writePluginEventsBlock`. Same `@hasDecl(plugin, "Events")` comptime walk, same `<plugin>__<event>` qualified tags, same `MergeHookPayloads` duplicate-field error path. The shim's `@import("<plugin>")` calls resolve through new `overrideImport(game_mod, "<plugin>", plugin_<n>_mod)` lines emitted by `build_files.generateBuildZig` (next to the existing sibling-plugin overrides). No new modules, no schema changes. **2. `MergedGameEvents` for `GameConfig`** (`src/main_zig.zig`, `labelle-engine/codegen/main.zig.template` — separate commit b5f8499 on labelle-engine `main`): The assembler now emits a `MergedGameEvents` decl next to `GameEvents` / `PluginEvents` and the engine main template substitutes it into the `GameConfig(..., GameEvents)` slot. Resolution: - no plugins, no game events → `void` (engine `has_events` stays false, every existing game unchanged) - game events only → `GameEvents` - plugins only → `PluginEvents` - both → `MergeHookPayloads(.{ GameEvents, PluginEvents })` `MergeHookPayloads` rejects `void`, so the per-case dispatch keeps the no-plugins/no-events shape `= void` directly. The engine's internal `Payload = MergeHookPayloads(.{ EnginePayload, GameEvents })` is untouched — `GameEvents` is just richer now when plugins contribute events, and `game.emit(.box2d__... = .{...})` resolves through the same buffered path `#422` already ships. Verified: - 449/453 tests pass (4 pre-existing skipped). New tests in `test/flow_scanner_tests.zig` pin the shim's PluginEvents inclusion for plugin-bearing projects and its omission for plain re-export projects. - bouncing-ball regenerates and `zig build` of `.labelle/raylib_desktop` succeeds end-to-end — closing the gap flagged in the labelle-box2d 6c44691 commit message ("bouncing-ball end-to-end build requires a matching labelle-assembler / labelle-engine change"). Out of scope (later phases): - Phase 4 (#175): collect flow handler structs into `GameHooks` receiver tuple in scanner-sorted order, wire `game_ptr`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…RFC-PLUGIN-EVENTS #3) Follow-up to d6591cf. The phase-3 fix for `game.emit(.box2d__... = {...})` typechecking originally took two paths: 1. Emit a separate `pub const MergedGameEvents` and pass it to `GameConfig` via a template change in labelle-engine (b5f8499 — now reverted, 9df107e). 2. Widen the existing `GameEvents` to fold in `PluginEvents` — keep the engine template's `GameEvents,` token verbatim. (This commit.) The template change was a version-skew trap: a cached engine template referencing `MergedGameEvents` would fail to substitute with any pre-phase-3 assembler (e.g. flying-platform-labelle's pinned 0.30.0 from cache), breaking unrelated games on a regeneration. The widened-`GameEvents` approach has no such coupling: `GameEvents,` stays the only token the template knows about, and its meaning widens only when plugins are in play. Changes: - **`src/main_zig.zig`** — game_events_block: - No plugins: emit `pub const GameEvents = void;` or `pub const GameEvents = union(enum) {...};` (the pre-RFC shape, verbatim — every shipped game keeps its exact v1 emission). - Plugins: emit `pub const GameEventsRaw = union(enum) {...};` (the events/*.zig scan, renamed-but-equivalent), then `pub const PluginEvents = blk: {...};` (the plugin walk), then widen `GameEvents`: - game events + plugin events → `pub const GameEvents = engine.core.MergeHookPayloads(.{ GameEventsRaw, PluginEvents });` - plugin events only → `pub const GameEvents = PluginEvents;` The engine template's `GameConfig(..., GameEvents)` then sees the merged type with no template churn. - **`src/main_zig.zig`** — all_hook_payloads_block: - Previous shape: `MergeHookPayloads(.{ HookPayload, GameEvents, PluginEvents })` - New shape: `MergeHookPayloads(.{ HookPayload, GameEvents })` — referencing `PluginEvents` again would re-emit every plugin variant twice and trip the merge's duplicate-field check. - **`test/flow_scanner_tests.zig`** — pin the new shape: - Plugin-only project → `pub const GameEvents = PluginEvents;` + `AllHookPayloads = MergeHookPayloads(.{ HookPayload, GameEvents })`. - Game events + plugin events → `pub const GameEventsRaw = union(enum) {...};` + `pub const GameEvents = MergeHookPayloads(.{ GameEventsRaw, PluginEvents })`. 449/453 tests pass (4 pre-existing skipped). bouncing-ball regenerates and `zig build` succeeds end-to-end with the widened `GameEvents` driving `game.emit(.box2d__collision_begin = .{...})`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…logic (review) Phase-2 bot review (window lifecycle plumbing): - windowShouldClose on Android returned (android_native_window == null), so at startup — before the NativeActivity hands over the surface — it reported 'should close' and the main loop would exit immediately (Gemini, high). Return false as the phase-2 placeholder; phase 3 (#302) wires the real onDestroy signal. - closeWindow didn't clear android_native_window (Cursor), leaving inconsistent state after teardown. Clear it. (Audio-module-on-Android (Gemini #3) is deferred to a tracked follow-up: the correct fix is a device-less audio module via a miniaudio extraction, and no bgfx-Android consumer exists until phase 4 — so it's a phase-4 prerequisite, not a phase-2 surface change.) Verified: android compile-check + desktop test still green.
…Window surface (phase 2, #301) (#305) * feat(bgfx): wire window/input/gfx for Android without zglfw + ANativeWindow surface (phase 2, #301) Replace the phase-1 Android early-return with real module wiring so the bgfx backend's window/input/gfx modules compile for aarch64-linux-android without zglfw (desktop-only), and plumb a native ANativeWindow surface into bgfx's PlatformData. - window.zig: comptime-gate zglfw behind is_android; split initWindow into desktop (GLFW) and Android paths. Android path sets PlatformData.nwh from an opaque ANativeWindow* handed over via new setAndroidNativeWindow(); ndt=null, type=.Default. closeWindow/windowShouldClose gated too. - input.zig: comptime-gate zglfw out for Android; stub key/mouse/gamepad and skip GLFW poll (real touch is phase 3, #302). Desktop unchanged. - build.zig: only fetch zglfw dep/artifact off the Android path; wire input_mod/window_mod with zglfw only on desktop; skip the audio (miniaudio) module on Android; install glfw artifact only on desktop. Hoist NDK sysroot resolution into resolveNdkPaths/NdkPaths. Add a gfx compile-check to the test step so all three Android modules are covered. Verified: zig build test -Dtarget=aarch64-linux-android (no zglfw), host zig build test, example link on macOS, and the phase-1 -Dtarget=aarch64-linux-android lib build all green. * fix(bgfx-android): correct windowShouldClose/closeWindow placeholder logic (review) Phase-2 bot review (window lifecycle plumbing): - windowShouldClose on Android returned (android_native_window == null), so at startup — before the NativeActivity hands over the surface — it reported 'should close' and the main loop would exit immediately (Gemini, high). Return false as the phase-2 placeholder; phase 3 (#302) wires the real onDestroy signal. - closeWindow didn't clear android_native_window (Cursor), leaving inconsistent state after teardown. Clear it. (Audio-module-on-Android (Gemini #3) is deferred to a tracked follow-up: the correct fix is a device-less audio module via a miniaudio extraction, and no bgfx-Android consumer exists until phase 4 — so it's a phase-4 prerequisite, not a phase-2 surface change.) Verified: android compile-check + desktop test still green.
chatgpt-codex raised 8 findings on PR #459 not covered by the #456 round. Verified each against the real code and folded the valid ones in: - root_build_deps now carry required resolution (url+hash/path/builtin); emsdk is a pinned template section, not name-synthesizable (#2) - bgfx-android android_app extra module requires root_alias="backend_app" (#3) - hookless mobile uses an assembler-owned default resolve_target; the backend-agnostic resolver means .resolved does not force a hook (#4) - build_hook must be a dedicated backend.hook.zig, not the provider build.zig (top-level @import("sokol") re-exports don't resolve in the root package) (#5) - android_target_sdk is required for Android; post_wire panics instead of the silent orelse 34 fallback (#6) - golden gate strengthened for hook-bearing cells: snapshot hook source and/or run the hook against a fixture *std.Build (#7) - carried v1 .capabilities forward into the v2 schema so opting into v2 doesn't bypass capability negotiation (.id was already present) (#8) Finding #1 (dep-option removal) was already resolved by ea24373 (base = universal options, per-platform = appends, no subtractive form) — recorded, not re-edited. Added a "PR #459 corrections" section documenting each. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
* docs(#453): fold PR #456 review findings into manifest-v2 design Revise the build-graph manifest v2 design doc to address the substantive coderabbitai/chatgpt-codex findings that PR #456 merged without incorporating, so the #453 item-3 implementation does not inherit the design flaws. Central corrections (all verified against the real code): - Dependency options are declarative (DepOption name + closed ValueSource predicate set), NOT a runtime pre_wire hook returning []Flag — a b.dependency options literal needs comptime-known field names. pre_wire/DependencyOptions deleted. - Target selection is a pre-dependency resolve_target phase (iOS device/sim + SDK, Android ABI) resolved from -Ddevice/-Demulator/-Dandroid_arch + host, not a static .triple; iOS SDK now computed before plugin b.dependency calls. - Core-diamond walk carries a gfx_mod singleton so it preserves engine->gfx. - Header-first bounded version parse (v1 stays readable, > SUPPORTED rejected). - Preserve backend_* import aliases; per-platform loop_style/artifacts/link_libc; root_build_deps for the emsdk wasm hook; android_target_sdk into HookContext. - Hook reframed as trusted build code (not mechanically sandboxable). - Byte-identical gate -> one desktop anchor + golden snapshots; packager PR moved before Android/wasm conversions. Docs-only. Adds a "Review corrections (PR #456)" summary section. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw * docs(#453): specify dep_options merge semantics + fix code-span spacing Address CodeRabbit findings on PR #459. Major — dep_options merge is now defined precisely: a comptime, name-keyed fold of per-platform entries over the base (override on collision, append otherwise), with NO subtractive form (an empty per-platform list inherits the base unchanged). Grounded in the v1 paramValue/param_names mechanism (manifest_splice.zig): the merge is a codegen-time operation on the NAME set, and because each platform's b.dependency literal is emitted independently, a name absent from a platform's folded set is simply never written. Corrected the sokol worked example to match build_zig.txt ground truth (:94/:124/:535/:763): with_imgui is the only base option (common to all four platforms); gamepad_* is a desktop-only append; dont_link_system_libs is an ios/android append (android's was missing). This dissolves the empty-wasm-list "drop" conflict — wasm forwards only with_imgui because gamepad_* was never in the base, not by removing it. Updated the "Review corrections" section to note the clarification. Minor — fixed inline code spans that wrapped across lines (MD038): the version gate spans (`< 1 or > SUPPORTED`, `2 <= v <= SUPPORTED_MANIFEST_VERSION`, `v > SUPPORTED`) and the `switch (target.result.os.tag)` span now sit on single lines. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw * docs(#453): fold PR #459 review findings into manifest-v2 design chatgpt-codex raised 8 findings on PR #459 not covered by the #456 round. Verified each against the real code and folded the valid ones in: - root_build_deps now carry required resolution (url+hash/path/builtin); emsdk is a pinned template section, not name-synthesizable (#2) - bgfx-android android_app extra module requires root_alias="backend_app" (#3) - hookless mobile uses an assembler-owned default resolve_target; the backend-agnostic resolver means .resolved does not force a hook (#4) - build_hook must be a dedicated backend.hook.zig, not the provider build.zig (top-level @import("sokol") re-exports don't resolve in the root package) (#5) - android_target_sdk is required for Android; post_wire panics instead of the silent orelse 34 fallback (#6) - golden gate strengthened for hook-bearing cells: snapshot hook source and/or run the hook against a fixture *std.Build (#7) - carried v1 .capabilities forward into the v2 schema so opting into v2 doesn't bypass capability negotiation (.id was already present) (#8) Finding #1 (dep-option removal) was already resolved by ea24373 (base = universal options, per-platform = appends, no subtractive form) — recorded, not re-edited. Added a "PR #459 corrections" section documenting each. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
#471) Convert bgfx, the last and hardest backend, to the manifest-v2 build graph (epic #453 item 3, PR 10, design §8). bgfx is the first backend needing BOTH per-platform loop_style (desktop .loop, android .callback) AND a platform-only extra_modules with a non-default root_alias (android's android_app → backend_app), both derived from ONE manifest. - backends/bgfx_v2/backend.manifest.v2.zon: faithful v2 translation of the real labelle-bgfx v1 manifest + enum backend_bgfx/link_bgfx/backend_bgfx_android/ android_link_bgfx sections. Desktop: .loop, .native, bgfx+glfw artifacts, base gui_enabled + desktop gamepad_* dep_options. Android: .callback, .resolved, .apk, pic, link_libc, bgfx artifact, android_app extra module aliased to backend_app, NDK system libs, build_hook. - backends/bgfx_v2/backend.hook.zig: std-only android resolve_target (ABI) + post_wire (bgfx-Android NDK residual: addLibraryPath + libc.txt; no artifact addSystemIncludePath since bgfx C++ TUs are backend-wired). android_target_sdk required (panics on null). Compiled as a test target in build.zig. - manifest_v2_splice: extend the android backend-dep emitter to declare + walk PlatformEntry.extra_modules under their root_alias (the backend_app alias — the load-bearing correctness bit, design §3 review-correction #3). - Golden cells: test/goldens/bgfx_v2_{desktop,android}.build.zig + assertions (AST-valid; desktop hookless + bgfx/glfw on exe AND test_root; android hook phases + backend_app alias + generic unifyCoreDiamond + apk packaging). Per-platform loop_style is already schema-supported (PlatformEntry); the emitter extension needed was extra_modules root_alias. sokol desktop byte anchor + all prior goldens unchanged; v1/enum path unchanged (v2 is opt-in). zig build test exit 0. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
…ins, pack-script order/staleness/context) (#500) * fix(packs): address codex findings from #494/#496 — scene-lint builtins exemption, pack-script order/staleness/context Four codex-review findings merged with the packs batch (#494, #496): 1. scene_name_lint.zig — exempt engine/gfx built-in component names (e.g. `VideoComponent`, always registered by `writeComponentRegistryBlock`) from the bare-pack-component lint. Previously a bare built-in was falsely flagged and mis-suggested to `pack__VideoComponent` when a pack shipped a same-named component. Adds `builtin_component_names` + exemption in `lintSource`. 2. root.zig — scan each light pack's `scripts/` at its `.plugins` declaration-order position (interleaved with plugins) so `ScriptScanner`'s `plugin_index` reflects `.plugins` order. The old two-phase scan (all plugins, then all packs) pushed a pack declared before a plugin behind it, breaking the per-state script ordering contract. 3. root.zig — when a pack ships no `scripts/` source, prune any stale dest a prior `generate` copied and register nothing. `copyAndScanAbs` no-ops on a missing source without touching the dest, so leftover copied scripts were otherwise scanned + compiled. Extracted `scanPackScriptsAt` (guards source existence) for both #2 and #3. 4. validate.zig / registries.zig — exclude pack/plugin `context` scripts (`plugin_name != null`) from the GameContext sentinel. A pack's `scripts/context.zig` no longer flips `hasContextEntry`, and `AllScripts` keeps importing it (instead of silently dropping it). Tests: builtin-exemption lint test; pack-before-plugin ordering test; stale-dest prune + present-script tests; pack-context emission test; hasContextEntry game-vs-pack tests. Also wired `codegen/validate.zig` into root.zig's test aggregator so its tests run under `zig build test`. `zig build` + `zig build test` green (1134 tests pass). Claude-Session: https://claude.ai/code/session_01P7B7UzgrWEbBYLT3YBrAog * fix(packs): propagate non-FileNotFound errors when probing pack scripts/ (codex P2 on #500) `scanPackScriptsAt`'s source-`scripts/` existence probe used a catch-all that treated ANY openDir failure — AccessDenied (permissions / broken mount), NotDir (`scripts` is a file), etc. — the same as a missing directory: it pruned the generated copy and silently dropped the pack's scripts, producing an incomplete build with no error. Mirror `copyAndScanAbs`'s source-root open (scanner.copyAndScanRecursive) EXACTLY: tolerate ONLY `error.FileNotFound` (prune stale dest + skip) and PROPAGATE every other error. Test: `scanPackScriptsAt propagates a non-FileNotFound probe error and does NOT prune (#500 codex)` — a pack whose `scripts` path is a file yields error.NotDir, which must propagate while the pre-existing generated copy stays intact. `zig build` + `zig build test` green (1135 tests pass). Claude-Session: https://claude.ai/code/session_01P7B7UzgrWEbBYLT3YBrAog
Four review findings on the upload-r2 step, plus one necessary follow-on: 1. Gate on R2_ACCOUNT_ID too. R2_ENDPOINT is built from it; if it were unset the endpoint would be malformed and the job would FAIL instead of taking the intended green-skip. Surface it via env and add it to the gate. 2. Set AWS_DEFAULT_REGION=auto — R2's S3 API requires a region. 3. Drop `pip install awscli`. aws is preinstalled on ubuntu-latest, and pip install breaks under Ubuntu 24.04's PEP 668 externally-managed env. 4. Don't regress assembler/latest.txt on old-tag backfills. A workflow_dispatch rebuild of an OLDER tag now uploads its per-version objects but only advances latest.txt when GITHUB_EVENT_NAME=push, so studio consumers that follow latest.txt aren't downgraded. Follow-on from #3: the preinstalled aws v2 (>=2.23) sends integrity checksums R2 rejects, so add AWS_REQUEST_CHECKSUM_CALCULATION / AWS_RESPONSE_CHECKSUM_VALIDATION = when_required (the same compat settings labelle-studio's R2 upload already uses). Without these, dropping pip (v1) for v2 would swap one failure for another. actionlint clean. CI can't run (org-wide Actions billing block), so this is verified statically. Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
- #1 escape generated string literals: emit the registry key AND @embedfile path through std.zig.fmtString (`{f}`), not raw `{s}` — a backslash/quote in an image source or asset_name (e.g. Windows `tiles\terrain.png`) no longer produces invalid Zig or a mis-keyed literal. - #2 same image key → different paths: `img_seen` now maps key→resolved embed path; two maps in different dirs both referencing `tiles.png` (different files, same runtime key) hard-error instead of silently reusing the first's bytes. Same-key-same-path stays a benign dedup. - #3 external tilesets: detect `<tileset source="*.tsx">` (external, no inline <image>) in `collect` and fail loud with the offending .tsx named — gfx returns error.ExternalTilesetUnsupported at runtime otherwise. Filed assembler#563. - #4 attribute syntax: `attrValue` now tolerates whitespace around `=` and single OR double quotes (`source = "x"`, `source='x'`), still a tight scan. Order: locate attr → strip quotes → XML-unescape. Tests: escaping round-trips to valid Zig (ast-parsed), diff-path collision, benign same-path dedup, external-tileset error, and the new attr syntaxes. zig build + test green, goldens byte-identical, fmt clean, files < 1000. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
- #1 free TMX buffers per iteration: extract the per-map read+scan into `processMap`, so each map's `.tmx` bytes (up to 8 MiB) + extracted image slice free at that frame's end instead of accumulating until `collect` returns. Registrations/keys are dup'd into `regs` first, so they outlive the per-map buffers. Fixes OOM risk for projects with many large maps. - #2 skip XML comments in the tag scan: `indexOfTagSkippingComments` skips `<!-- ... -->` spans, so a commented-out `<image>`/`<tileset source>` no longer emits a bogus @embedfile (which could fail the build) or a false external-tileset error. - #3 include pack prefabs in the prefab fail-loud: move the tilemap phase after `loadPackScans` and thread `pack_scans` into `collectRegistrations`; `failOnPrefabTilemaps` now also walks `<import_prefix>/prefabs/*.jsonc`, so a Tilemap in a light-pack prefab aborts with the same #561 message instead of shipping a silently-broken binary. Updated #561 body. Tests: commented-out <image>/<tileset> ignored (real one still found); existing collect tests exercise per-map frees under testing.allocator. Verified e2e: pack-prefab Tilemap fails loud; happy-path unchanged + ast-checks. zig build + test green, goldens byte-identical, fmt clean, files < 1000. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
…#559) * feat(release): publish binaries to R2 under assembler/ (matches cli/) Mirror labelle-cli's upload-r2 job so the assembler binaries also land on the shared R2 bucket (labelle-releases) under the assembler/ prefix: s3://$R2_BUCKET/assembler/<tag>/labelle-assembler-<os>-<arch> plus a plaintext assembler/latest.txt pointer. labelle-studio's fetch-sidecars sources the assembler sidecar from here; the existing GitHub release stays as its fallback. The new upload-r2 job runs alongside `release` (both only need `build`) so an R2 hiccup never blocks the GitHub release, and vice versa. The whole upload is gated on the credential secrets being present, so this is green NOW: R2_ACCOUNT_ID + R2_BUCKET are already set; the API-token pair R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY is not yet. It lights up automatically once those are added and the next tag is cut. Companion to labelle-studio: source the assembler sidecar from R2 with GitHub fallback. Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j * fix(release): harden R2 upload step (coderabbit + codex review) Four review findings on the upload-r2 step, plus one necessary follow-on: 1. Gate on R2_ACCOUNT_ID too. R2_ENDPOINT is built from it; if it were unset the endpoint would be malformed and the job would FAIL instead of taking the intended green-skip. Surface it via env and add it to the gate. 2. Set AWS_DEFAULT_REGION=auto — R2's S3 API requires a region. 3. Drop `pip install awscli`. aws is preinstalled on ubuntu-latest, and pip install breaks under Ubuntu 24.04's PEP 668 externally-managed env. 4. Don't regress assembler/latest.txt on old-tag backfills. A workflow_dispatch rebuild of an OLDER tag now uploads its per-version objects but only advances latest.txt when GITHUB_EVENT_NAME=push, so studio consumers that follow latest.txt aren't downgraded. Follow-on from #3: the preinstalled aws v2 (>=2.23) sends integrity checksums R2 rejects, so add AWS_REQUEST_CHECKSUM_CALCULATION / AWS_RESPONSE_CHECKSUM_VALIDATION = when_required (the same compat settings labelle-studio's R2 upload already uses). Without these, dropping pip (v1) for v2 would swap one failure for another. actionlint clean. CI can't run (org-wide Actions billing block), so this is verified statically. Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
…Phase 4) (#560) * tilemap: scan scenes for Tilemap + embed .tmx and tileset images (T2 Phase 4) Scan scene JSONC for `Tilemap` components, resolve each `asset_name` to a `.tmx` under the project's `assets/` dir, and comptime-embed the `.tmx` plus every tileset `<image source>` it references so labelle-engine v1.75.0 can decode + render the tilemap. Engine contract (labelle-engine v1.75.0, game/tilemap_mixin.zig): a single `Game.addEmbeddedTilemapAsset(name, bytes)` registry is keyed by BOTH the scene `asset_name` (-> .tmx bytes) AND each tileset's verbatim `image_source` string (-> image bytes; the engine's `ImageProvider.get` looks up by that exact string). The generated `init()` now populates it. - scene_manifest: `SceneManifest.tilemap_assets` — deep-walk the entity tree for `Tilemap` components (flat/wrapped/children/bundle shapes). - tilemap_scan.zig: resolve `asset_name` -> `assets/<asset_name>.tmx`, read it, extract `<image source>` refs (tight XML scan), and build a deduped flat list of `addEmbeddedTilemapAsset` registrations. Image registry key = verbatim `image_source`; @embedfile path = resolved relative to the .tmx dir. - codegen: emit registrations before `setScene` in both lifecycle paths (loop `try`, callback `catch @panic`), via the module-level-var pattern used by pack_scans. Purely additive — empty for tilemap-free projects. - Bump default engine_version 1.60.0 -> 1.75.0. Tests: scan extractor + path convention + collect (tmpDir) + scene extraction + both emit spellings. Verified e2e: a null-backend fixture generates registrations before setScene and passes `zig ast-check`. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw * tilemap: re-pin default engine to 1.75.1 (fixes null-backend tilemap compile) engine 1.75.0's tilemap_runtime failed to compile against any gfx-1.21.0 backend (generic getTextureInfo seam) — the Examples integration test's null-backend asset-streaming-smoke build caught it. 1.75.1 (engine#707) hardens supported() + derives the texture type from the concrete resolver fn. Brings the fix in transitively (engine 1.75.1 pins gfx 1.21.0). Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw * tilemap: address codex review (P1 sibling pins + 5 P2 correctness fixes) P1 — bump core/gfx defaults to match engine 1.75.1's transitive pins so a fresh `labelle init && build` resolves a compatible set: core 1.24.0, gfx 1.21.0 (read from engine v1.75.1 → gfx v1.21.0 → core v1.24.0 build.zig.zon). P2 fixes: - Walker over-match: replace the generic deep scan with a STRUCTURED entity-tree walk (`tilemap_scene_scan.zig`) — a `Tilemap` key is only collected when it's a direct member of an entity's `components`/`overrides` map or a flat-form component key, never nested in another component's data. - tmx/image key collision: track the `.tmx` and image key spaces separately and hard-error (`TilemapKeyCollision`) when a scene `asset_name` collides with a tileset `<image source>` in the shared engine registry. - XML-unescape `<image source>` (& < > " ') before using it as the @embedfile path AND the registry key — gfx hands the engine the DECODED string. - Honor project-registered `Tilemap` (engine C2): skip built-in embedding when a project component pascal-matches `Tilemap`. Plugin/pack-registered Tilemap deferred (fails loud via missing-asset) — filed assembler#562. - Prefab-borne Tilemaps: not embedded in minimal-T2 — detect + fail loud with a clear message instead of a silently broken binary. Filed #561. Refactor to keep files < 1000 lines: extract the JSON walker into `tilemap_scene_scan.zig` and the generate-phase policy into `root/tilemap_phase.zig`. Tests: walker over-match (nested non-component Tilemap), key collision, XML-unescape, prefab-scan detection. zig build + test green, fmt clean. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw * tilemap: address codex 2nd-pass review (4 P2 correctness fixes) - #1 escape generated string literals: emit the registry key AND @embedfile path through std.zig.fmtString (`{f}`), not raw `{s}` — a backslash/quote in an image source or asset_name (e.g. Windows `tiles\terrain.png`) no longer produces invalid Zig or a mis-keyed literal. - #2 same image key → different paths: `img_seen` now maps key→resolved embed path; two maps in different dirs both referencing `tiles.png` (different files, same runtime key) hard-error instead of silently reusing the first's bytes. Same-key-same-path stays a benign dedup. - #3 external tilesets: detect `<tileset source="*.tsx">` (external, no inline <image>) in `collect` and fail loud with the offending .tsx named — gfx returns error.ExternalTilesetUnsupported at runtime otherwise. Filed assembler#563. - #4 attribute syntax: `attrValue` now tolerates whitespace around `=` and single OR double quotes (`source = "x"`, `source='x'`), still a tight scan. Order: locate attr → strip quotes → XML-unescape. Tests: escaping round-trips to valid Zig (ast-parsed), diff-path collision, benign same-path dedup, external-tileset error, and the new attr syntaxes. zig build + test green, goldens byte-identical, fmt clean, files < 1000. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw * tilemap: address codex 3rd-pass review (3 P2 robustness fixes) - #1 free TMX buffers per iteration: extract the per-map read+scan into `processMap`, so each map's `.tmx` bytes (up to 8 MiB) + extracted image slice free at that frame's end instead of accumulating until `collect` returns. Registrations/keys are dup'd into `regs` first, so they outlive the per-map buffers. Fixes OOM risk for projects with many large maps. - #2 skip XML comments in the tag scan: `indexOfTagSkippingComments` skips `<!-- ... -->` spans, so a commented-out `<image>`/`<tileset source>` no longer emits a bogus @embedfile (which could fail the build) or a false external-tileset error. - #3 include pack prefabs in the prefab fail-loud: move the tilemap phase after `loadPackScans` and thread `pack_scans` into `collectRegistrations`; `failOnPrefabTilemaps` now also walks `<import_prefix>/prefabs/*.jsonc`, so a Tilemap in a light-pack prefab aborts with the same #561 message instead of shipping a silently-broken binary. Updated #561 body. Tests: commented-out <image>/<tileset> ignored (real one still found); existing collect tests exercise per-map frees under testing.allocator. Verified e2e: pack-prefab Tilemap fails loud; happy-path unchanged + ast-checks. zig build + test green, goldens byte-identical, fmt clean, files < 1000. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw * tilemap: address codex 4th-pass review (#A tileset-scoped scan, #B backslash key/path) + revert unescape per gfx Grounded in gfx v1.21.0 tilemap/src/root.zig: its TMX parser stores `image_source` as a RAW dupe of the attribute-value bytes (parseAttributes reads verbatim between double-quotes; `tileset.image_source = dupe(src)`) — NO XML-entity decoding, NO separator normalization. The engine's ImageProvider.get looks up by that raw string, so the registry key must match it byte-for-byte. - #A tileset-scoped image scan: `extractImageSources` now only collects `<image>` INSIDE a `<tileset>…</tileset>` span. An `<imagelayer><image/>` background is ignored — the engine fetches images only for decoded TILESETS, so embedding an imagelayer image would require an absent file / collide though the runtime never requests it. - #B Windows backslash: the registry KEY stays the RAW `image_source` (backslash intact, matching gfx's lookup); only the @embedfile PATH normalizes `\`→`/` (+ existing `.`/`..` collapse) so it resolves to the copied asset `assets/tiles/terrain.png`. - Revert round-2 xmlUnescape: gfx does NOT decode entities, so decoding the key was a silent-mismatch bug (engine keys by raw `&…`). Keys/paths are now raw; the ambiguous XML-entity-in-path edge is deferred + filed as assembler#564. Tests: imagelayer image ignored (tileset image still found); raw source preserved (no decode); backslash → raw key + normalized path (unit + collect e2e). zig build + test green, goldens byte-identical, fmt clean, files < 1000. Happy-path fixture regenerates identical + ast-checks. Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
…latform#786 friction #2/#3) (#663) * feat(i18n): keyword-key lint + null-terminated t() variants (flying-platform#786 friction #2/#3) Friction #2 -- Zig-keyword key segments (FP had to rename pause.resume -> pause.resume_game after the fact): - new src/zig_keywords.zig: detection defers to std.zig.Token.getKeyword (the compiler's own table, never drifts) + the @""-quoted call-site path builder. Primitives (bool, u8) deliberately unlinted: K.hud.bool is ordinary field access. - i18n phase: generation-time warning against the reference locale (the key space -- one warning per key however many files carry it) and each pack's reference, naming file, key, offending segment, the exact K.pause.@"resume" call-site spelling and the resume_ rename. Warning, never an error: the @"" path works (usage_scan covers quoted segments since #656 round 3). Diagnostics-gated like the coverage warnings. - constants phase: same lint, same shared helper, over defining yaml files with file:line, plus a keyword filename namespace (error.yaml -> C.@"error"). Override files skipped -- linted where the pack defined the keys. Pure collectors do the finding (unit-testable without capturing stderr); runPhase owns the printing. Friction #3 -- null-terminated access for cimgui: VERDICT, already sound since #656, so no tz/tfz siblings. t() returns [:0]const u8 off the sentinel-typed literal table (decoded-escape strings re-emitted as literals, so they carry the NUL too); tf() returns [:0]const u8 and the ring discipline holds through truncation (appendBytes never consumes the last byte, the sentinel lands in-bounds and frame_len steps past it, wrap only between results). Shipped the regression locks instead: - text-level signature/table asserts beside the emitter - test/i18n_sentinel_tests.zig: generates a real i18n.zig, then COMPILES AND RUNS a harness against it (zig test via the #586 zig_exe seam) -- comptime @typeof asserts + runtime .ptr[len]==0 probes over the static, decoded, keyword-keyed and ring-formatted paths - src/root.zig: i18n_phase goes pub for that suite Plurals (tp/tpf, #660) untouched -- #660 is still open. Projects without locales/ or constants/ stay byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * fix(i18n): one lint warning per surfaced pack key; quote non-bare segments in diagnostic paths CodeRabbit round 1 (both Minor, both real): - a game override of a keyword-bearing pack key warned twice -- once from the game-side pass, once from the pack-side pass over the same surfaced key. The pack's key space owns the diagnostic now: the game-side pass skips pack-namespaced keys (same raw-name `<pack>__` prefixing mergePacks uses). - quoteDottedPath keys off std.zig.isValidId instead of isKeyword alone: i18n composes surfaced paths from the RAW pack name, so a segment like `my-pack__hunger` needs @"" for shape, not keyword-ness -- the shown call-site path is now valid Zig for those packs too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * Merge main (plurals #660) — keep both i18n_phase test suites The keyword-lint/sentinel tests and the phase-4 plural tests were added at the same insertion points; the zipper resolution keeps main's test region intact and re-appends this branch's four test blocks. Failure set verified against main's baseline: zero new. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * fix merge: take main's i18n_locales (plural Value union) wholesale The earlier resolution regressed i18n_locales.zig to the pre-#660 shape (this branch never touched the file), which broke the exe compile at the activeTag shape check. Restored main's version verbatim and moved the keyword-lint test fixtures to the union spelling (.value = .{ .str = .. }). Exe builds; test failure set is byte-identical to main's baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Summary
AndroidConfigstruct (package_name, min/target SDK, orientation)build_zig.txt: NDK sysroot detection, shared library output, GLES3/EGL/libandroid linkingbuild_files.zigmirroring the iOS patternKey difference from iOS: output is a
.so(shared library) for NativeActivity, not an executable.Closes labelle-toolkit/labelle-cli#133
Test plan
zig buildcompiles🤖 Generated with Claude Code