fix(release): keep a changeset's markdown in the changelog
build_section piped every changeset body through `tr '\n' ' '`. A multi-line changeset came out as one paragraph, so nested bullets rendered as inline " - " runs and a whole release read as a single unbroken block — v0.3.0 was one ~4 KB bullet. The renderer is now Ludic rather than a shell one-liner. A section is grouped by conventional-commit type (Features, Fixes, Performance, ...), each changeset is one bullet, and continuation lines are indented two spaces so nested lists and paragraphs stay inside their item. Bullets are sorted within a group, so cutting the same release twice produces the same text. Also: - `x release --dry-run` renders the pending section to stdout and touches nothing, so a release can be read before it is cut. - `x changelog-render` re-renders a section from a directory of changesets, and `x changelog-section` prints one release's section back out of CHANGELOG.md. - The v0.1.0 and v0.3.0 sections are re-rendered with the former, from the changesets recovered at each tag's parent commit; the bullet counts (6 and 25) and word multisets are unchanged. v0.2.0 is left alone: it carries a hand-written summary and topical subheadings, and regenerating it would have replaced curation with raw changeset dumps. The file header now says that a section may carry such a summary, since it previously claimed released sections are never hand-edited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2f3833a295
commit
5c4c10a1d7
5 changed files with 400 additions and 56 deletions
89
CHANGELOG.md
89
CHANGELOG.md
|
|
@ -1,35 +1,53 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to the Ludic toolchain, newest first. Generated from the
|
||||
changesets under changes/ by x release; do not edit released sections by hand.
|
||||
All notable changes to the Ludic toolchain, newest first. Each section is
|
||||
generated from the changesets under changes/ by `x release`, grouped by change
|
||||
type. Preview the next one with `x release --dry-run`.
|
||||
|
||||
A released section may carry a hand-written summary paragraph above its groups
|
||||
(v0.2.0 has one); the generated bullets below it are not edited by hand.
|
||||
|
||||
## v0.3.0 — 2026-09-02
|
||||
|
||||
- **feat**: **Tiled map support (#67–#74)** — load and draw [Tiled](https://www.mapeditor.org/) maps (TMX/TSX/TX and TMJ/TSJ/TJ), the design record from #66. - **P0 parsing primitives (#67)** — a minimal pure-Ludic XML reader (`Xml.*`) for the element/attribute/CDATA subset TMX/TSX/TX use; standard base64 decode/encode (`Base64.*`, RFC 4648), whose decoder ignores the whitespace Tiled wraps into `<data>`; and gzip framing (`z_gunzip`, RFC 1952) wrapping the existing DEFLATE inflater. zlib and the JSON reader already shipped. A curated, attributed golden corpus lands under `assets/tiled-fixtures/`. - **P0.5 TMX/TSX reader (#68)** — `Tiled.read` / `Tiled.read_tsx` map the native XML formats onto the *same* intermediate the JSON path produces — a `Value` tree in Tiled's JSON schema, with every tile layer's data decoded to a dense GID list (CSV, base64, base64+zlib, base64+gzip). A CSV `.tmx` and a base64+zlib `.tmj` of the same map read structurally identically; the in-repo Kenney `sampleMap.tmx` + external `sampleSheet.tsx` load with no manual JSON re-export. - **P1 core load + render (#69)** — the runtime `rt_tmap` model (heap-allocated to `w·h`, lifting the old `96×64` cap), the GID resolver (`Tiled.resolve` → tileset / local id / H·V·D flips), image-backed rendering (`Tiled.draw`, flips applied at blit), and the legacy-tilemap compatibility projection so `Grid.*`/`Path.*`/`esys_move` keep working. `Tiled.load` reads either format, resolves external tilesets + images, and auto-projects a `collision` layer. The Kenney sample loads and renders pixel-identically from `.tmx` and `.tmj`; the `grid` and `physics_tiles` demos now run off a loaded map. - **P2 collision & grid (#70)** — normalise three collision sources into the byte tilemap `esys_move`/`Grid.*`/`Path.*` read, in the design's priority order: per-tile `<objectgroup>` hitboxes, the `solid`/`oneway`/`trigger` property convention (`Tiled.collision_kind`/`Tiled.tile_shapes`), and the designated collision layer (`Tiled.project`, any non-zero GID solid) — or drive collision from a visual layer's per-tile metadata alone (`Tiled.collide`). The property convention and the collision-layer fallback produce the same feed; `Path.a_star` over a loaded map matches the hand-authored baseline. - **P3 animated tiles + tile objects (#71)** — a tileset `<animation>` advances deterministically as a pure function of the fixed 60/s engine frame clock (`Tiled.frame_gid`/`Tiled.animated`), so an animated GID resolves at draw to the current frame's GID with its flip flags preserved and reproduces frame-for-frame across runs; `Tiled.draw_anim` draws a map with animations advanced. Object-layer entries with a `gid` render the tile image (with their own flips), bottom-anchored, as placeable sprites. - **P4 objects, properties, templates, spawning (#72)** — all object shapes (rectangle / ellipse / point / polygon / polyline / text) and custom properties parse and are queryable (`Tiled.object`, `Tiled.object_shape`, `Tiled.prop`/`Tiled.prop_int`/`Tiled.prop_type`); class properties resolve their defaults against a project custom-type table (`Tiled.load_types` over `objecttypes.xml`); template instances inherit their `.tx`/`.tj` template's fields; and an object maps onto Ludic components on demand (`Tiled.spawn`/`Tiled.spawn_layer`, off by default, via the reflection ABI). - **P5 breadth (#73)** — image layers (parallax + repeat) and group layers (flattened, with recursive offset/opacity/tint/visible; `Tiled.layer_kind`/`Tiled.layer_offsetx`/`Tiled.layer_tint`); the isometric / staggered / hexagonal orientation coordinate transforms (`Tiled.cell_x`/`Tiled.cell_y`, driving the tile draw so cells land at the correct screen coords); and Wang-set GID resolution through the standard resolver (the terrain-corner authoring concept is editor-side and ignored). - **P6 scale (#74)** — infinite/chunked maps: `<chunk>` (TMX) and JSON `chunks[]` decode and flatten into the dense layer; `.world` stitching (`Tiled.world`/`Tiled.world_count`/`Tiled.world_map`) lists member maps at their offsets; and a self-contained pure-Ludic **zstd** decompressor (`z_zstd`, RFC 8878) for base64+zstd layers — frame + raw/RLE/compressed blocks, raw/RLE/direct-weight-Huffman literals, and the full FSE sequence path — decoding the low-entropy GID streams a tilemap produces (a high-entropy FSE-compressed-Huffman-weights block fails cleanly with -1 rather than emitting wrong bytes).
|
||||
- **feat**: Builtin NPC AI (#61) — the source package **ludic.npcai**, a perception → decision → action stack that plugs into the other controllers instead of re-implementing movement. The AI never moves a body directly: it writes the SAME intent fields the player controllers read (`want_x`/`want_y`/`want_fire`, `want_jump`), so an enemy gunner reuses the shooter's weapon/projectile/auto-aim systems verbatim (set the body's `TopDown.aim_mode = 3`) and a companion reuses the mover — friendly vs enemy is faction + goal, not different code. **Perception** (`Vision` + `Memory`, throttled `esys_perception` with faction filtering and optional `Grid` line-of-sight) remembers the nearest hostile and emits `TargetSpotted`/`TargetLost`. **Decision** offers three models writing one `Brain` intent — a finite-state machine (patrol/chase/attack/flee), a utility scorer, and a canonical behaviour tree — each decision veto-able via `cancellable DecisionMade`. **Steering** adds Reynolds flocking (separate/cohere), and a `Follower` component gives companion stances. Reuses ludic.gameplay Faction (who is hostile) + Stats (hp for flee). Fully deterministic: perception + replan are frame-throttled and fixed-order. Example: `examples/games/npcai_demo.ludic` — one enemy perceives, chases and shoots a target through the shooter controller, flees at low hp under the utility model, and a companion follows its leader.
|
||||
- **feat**: Builtin Platformer controller (#58) — the reference implementation of the six-lever extensibility contract, shipped as the source package **ludic.platformer**. Movement feel is all defaulted POD data (`Platformer { move_speed, jump_height, apex_frames, fall_gravity_mul, coyote_frames, jump_buffer_frames, air_jumps, policy, … }`); the controller is decomposed into small engine-owned sub-systems — input (Input phase), move/gravity/jump (FixedUpdate, before the shared `esys_move` sweep) and animation-state (LateUpdate, after it) — each independently switch-off-able with `disable system <fn>`. It owns movement *policy* only and reuses the engine Body/Collider swept-AABB collision. Jump feel derives gravity + impulse from height/apex, with coyote time, jump buffering, variable jump height and multi-jump; every jump decision emits a `cancellable JumpRequested` / `JumpPerformed` / `Landed` / `StateChanged` event, and a gravity `policy` enum (asymmetric / symmetric / floaty) is the formula hook. Ships the opt-in game-loop layer too (`scaffolding.ludic`): moving & crumbling platform blocks with rider carry, collectibles + `Score`, springs, hazards + a light `Life`/i-frames model, and checkpoint/goal triggers. Deterministic integer Q16.16 throughout. Examples: `examples/games/platformer_demo.ludic`, `examples/games/platformer_scaffolding.ludic`. Also fixes a latent codegen bug in `ludic_sweep_entity` (an SSA register name collided once a program declared ≥11 events).
|
||||
- **feat**: Builtin RPG systems suite (#59) — the source package **ludic.rpg**, seven independently-usable modules on the six-lever contract, with name-keyed data registries so a game or mod adds content with zero code. **A Movement** — one `Mover` with a `mode` selector (grid / free / grid-tween) and 4/8-axis, tilemap walkability, and `cancellable MoveRequested` (locked doors/ice) / `TileEntered` (encounters) / `Interacted` (the action-button raycast). **B Inventory** — a name-keyed item registry, per-owner counts, gold, `ItemUse` veto, and `Equipment` whose bonuses flow through the gameplay Stats modifier stack. **C Crafting** — a data-driven recipe + ingredient registry; `Craft.can`/`Craft.make` consume from the inventory. **D Quests** — quests + objectives whose progress is driven by `Quest.notify` (route any gameplay signal in), auto-completing when met, plus a global flag store for branching. **E Dialog** — an Ink/Yarn-style graph registry (nodes + choices) with a per-speaker `Dialog` component and `cancellable DialogChoice` for skill-check gating. **F Puzzles** — Sokoban `Pushable` + a switch / pressure-plate / gate signal graph (logic puzzles with no code). **G Status** — over-time poison/regen effects routed through the shared Combat pipeline. Everything is integer-deterministic, so save/load (world_save) and rollback hold. Example: `examples/games/rpg_demo.ludic` (21 self-checks across all seven modules).
|
||||
- **feat**: Builtin top-down Shooter controller (#60) — the source package **ludic.shooter**, conforming to the six-lever contract and built on the engine Body/Collider + ludic.gameplay Faction/Combat/Stats. `TopDown` decouples movement from aim (`aim_mode`: mouse / right-stick / move-direction / nearest-enemy auto-aim, with a `turn_rate` for tank-style rotation). Weapons are a name-keyed **registry** (`Weapon.def("shotgun", …)` — add a gun with zero code) with per-weapon fire-rate, damage, speed, spread, pellet count, pattern (single / spread cone / ring / spiral), plus data-driven pierce (`Weapon.set_pierce`) and homing (`Weapon.set_homing`); `esys_weapon` reads a `want_fire` intent so the **same** weapon fires for a player (input) and an NPC (AI). `Projectile` + `esys_projectile` is a self-contained deterministic pool: integrate, TTL, faction-filtered hit through `Combat.damage`, pierce, and homing that curves onto the nearest enemy — every step observable via `ProjectileSpawned` (mutable/veto) / `ProjectileHit` / `ProjectileExpired`. A budgeted `Spawner` wave director emits `SpawnRequested` / `WaveCleared`. Example: `examples/games/shooter_demo.ludic` (11 self-checks: movement, aim, faction damage, friendly-fire immunity, spread, kill, ring, homing, waves).
|
||||
- **feat**: Canonical engine-ABI components (#77) — the shared `Position` / `Body` / `Collider` / `Solids` bundles the engine-owned movement system (`esys_move`, #65) reads by name are now shipped from a base source package, **ludic.core**, instead of being re-declared by hand in every game and example. A game `import "ludic.core/components.ludic"` and the engine moves and collides its entities for free; extend by *composition* (attach your own components on the same model). AOT means the properties compile straight into the consumer's compile-time ECS with no ABI seam, and everything stays integer + Q16.16 deterministic (lockstep / replay / `world_save` hold). Example: `examples/library/core_components.ludic`.
|
||||
- **feat**: Cursor capture (#89) — `Input.cursor_mode(mode)` hides / locks / confines the OS mouse for a windowed game: `0` normal (visible, free), `1` hidden (hide the OS cursor while focused so a game draws its own reticle), `2` locked (hidden + dissociated — the mouse feeds *relative* motion through `Input.mouse_dx/dy`, and `Input.mouse_x/y` becomes a clamped virtual cursor, the FPS / twin-stick aim mode), `3` confined (dissociated but visible; the mouse cannot leave the window). The platform auto-releases (shows + reconnects the cursor) while the window is not key (Cmd-Tab) and on close, so the cursor is never left captured. Adds the native macOS implementation in `cocoa.ll` (`[NSCursor hide]/[unhide]`, ref-counted and toggled only on change; `CGAssociateMouseAndMouseCursorPosition`; `CGGetLastMouseDelta` for the relative virtual cursor) behind a new `win_cursor_mode` intrinsic; headless / non-windowed it is a no-op (DCE'd). Example: `examples/library/cursor_capture.ludic`.
|
||||
- **feat**: Declarative Render clear + present (#86) — `@ClearColor(0xRRGGBB)` makes the engine own the per-frame clear and flip: at the top of the Render phase it clears the framebuffer to the declared colour, and after the Render handlers run it presents the frame, so a game's Render handler no longer repeats `Screen.clear(color)` / `Screen.show()` and the clear colour is configured *declaratively* rather than in the handler body. Opt-in and backward-compatible: a program with no `@ClearColor` is byte-for-byte identical (it clears/presents itself, or the light system owns the present). Example: `examples/library/clear_color.ludic`.
|
||||
- **feat**: Deterministic camera zoom (#78) — `Camera.zoom(scale)` scales the whole view about the screen centre by a Q16.16 factor (`1.0` = none, `2.0` = 2x in, `0.5` = out). It rides on the same two framebuffer chokepoints (`rt_put_px` / `rt_fill_rect`) that already carry the camera offset, so it composes with `Camera.set`/`follow`/`shake`, and it is a *render-time* transform — the world coordinate types stay integer pixels + Q16.16 velocity, so lockstep, replay and `world_save` are untouched, and the zoom itself is deterministic. Gated by an internal `rt_cam_zoomed` flag so a game that never zooms renders byte-for-byte identically (golden renders unchanged); `Camera.zoom(1.0)` turns it back off. This is the concrete outcome of the #78 position-types investigation (`docs/RFC-POSITION-TYPES.md`), which rejected hardware floats for the deterministic coordinate core and identified zoom as the one genuinely-missing render feature. Example: `examples/library/camera_zoom.ludic` (pixel-readback verified).
|
||||
- **feat**: Directional int input (#79) — `Input.axis_i(neg, pos) -> int` returns a -1/0/1 movement intent (`+1` positive key held, `-1` negative, `0` neither or both) read from the multi-key device set, so turning WASD into movement no longer needs the `ki(key_down('d')) - ki(key_down('a'))` bool-to-int glue and feeds an int mover directly: `dx = Input.axis_i('a', 'd')`, `dy = Input.axis_i('w', 's')`. Complements `Input.axis` (fixed) / `Input.vector` (normalized). Example: `examples/library/input_movement.ludic`.
|
||||
- **feat**: Engine sprite-render system (#85) — the engine already auto-*ticks* SpriteAnim and Motion; it now auto-*draws* too. Declare a `Sprite` component (id + optional offx/offy/scale/flip/tint/hidden, shipped from **ludic.core**) on an entity with a `Position` and the engine draws it each Render frame — no hand-written Render handler querying positions and calling `draw_sprite` per entity, and no hand animation (when the entity also carries `SpriteAnim`, the current frame is added to the base id). Registered on the compile-time engine-system registry for the Render phase and spliced only when a game declares `Sprite`, so a game that never declares it compiles byte-identically; a game wanting a custom draw omits `Sprite` (or `disable system esys_sprite`). Also **deprecates the bare `draw_sprite` / `draw_sprite_scaled` globals** in favour of the namespaced `Screen.sprite` / `Screen.sprite_scaled`: a direct bare call now emits a one-time compile-time deprecation note (the bare form still lowers, since `Screen.sprite` uses it), and the in-repo `chronorift` demo is migrated to the namespaced calls. Example: `examples/library/sprite_render.ludic`.
|
||||
- **feat**: Entity-pool stats (#80). Ludic's ECS is already pool-based: the allocator recycles freed entity slots through a freelist (a `despawn`ed slot is reused by the next `spawn` before any new slot is taken), and component storage is fixed per-entity arrays — so spawning and despawning many entities per frame (bullet-hell / horde) does **no per-spawn heap allocation** and cannot fragment. Exposes that with a `Pool.*` namespace so a game can watch the reuse and budget against the cap: `Pool.live()` (entities alive now), `Pool.free()` (freed slots waiting to be reused), `Pool.reserved()` (high-water — slots ever allocated; stays flat across a steady spawn/despawn loop, the proof that slots are pooled not reallocated), and `Pool.capacity()` (the fixed entity cap). Zero-cost — they read the existing allocator counters inline. Example: `examples/library/pool.ludic`.
|
||||
- **feat**: Gameplay-controller foundation (#57) — the shared, cross-genre building blocks the builtin controllers stand on, shipped as the source package **ludic.gameplay**: a deterministic `Cooldown` frame timer (engine-ticked), a `Stats` attribute bundle with an unbounded timed **modifier stack** (`Stats.total` computes base+flat then percent on demand; expired modifiers self-despawn), a `Faction` friend/enemy/neutral relationship table (same-id-friendly / different-hostile by default), and a `Combat` damage pipeline whose `cancellable DamageAboutToApply` hook lets a game veto a hit *or rewrite the amount* (`Combat.set_amount`) and which emits `Damaged`/`Died`/`Healed`. Everything is integer-only so lockstep, replay and `world_save` snapshots hold. Also adds extensibility **lever 5** to the language: `disable system <esys_fn>` drops exactly one engine-owned system's tick at compile time, so a game can carry a well-known component but tick it with its own handler (byte-identical when nothing is disabled; the C-free bootstrap fixpoint is untouched). Example: `examples/library/gameplay_foundation.ludic`.
|
||||
- **feat**: Incremental asset preloading (#82). Assets used to load synchronously inside Boot/Start (`png_load`, `Audio.load`), stalling the first frame(s) as content grows, with no built-in loading phase. Adds an `Assets.*` preload queue: `Assets.enqueue(name, path)` queues a named image without loading it, `Assets.pump(max)` loads up to `max` queued assets per frame (returning how many it loaded), and `Assets.total` / `loaded` / `ready` / `progress` (0..100) drive a progress bar. A loading scene pumps a few assets per frame, draws `Assets.progress()`, and `become`s the play scene once `Assets.ready()`, so the game shows a responsive loading screen and only enters play once content is ready — the deterministic, no-threads form of async preloading (the work is spread across frames instead of stalling one, and the same enqueue+pump order loads identically every run). Loaded assets are reachable by name via `Assets.get` / `Sprite.named`. Builds on the #81 atlas. Example: `examples/library/preload.ludic`.
|
||||
- **feat**: Input Manager + automatic device-layer drive (#83). The generated frame loop now commits the input device layer itself — when a game uses any Input action-map / device method it calls `input_poll` each frame (reading the live key, recording/replaying, and rebuilding the held-key/mouse/gamepad state), so `Input.active` / `Input.key_down` / the mouse and pads read live **without the game calling `Input.poll` by hand** (previously the loop fed only `Input.key`, and the device layer read empty unless the game polled at the top of its Input phase). A game that uses no Input runtime keeps the plain `rt_poll` path, byte-identical. Adds the Input-Manager API on top: `Input.action(name, key)` ships a **default** binding (kept if already bound, so a player's `Input.rebind` or a loaded key-map is not clobbered); `Input.bind_pad(name, button)` makes an action **device-agnostic** (fires from keyboard *or* gamepad); and `Input.active` / `Input.just_pressed` / `Input.just_released` read the whole multi-key device layer with clean on-press / on-release edges (the deterministic, dispatch-free equivalent of event handlers — a handler polls the edge and reacts, so a replay fires identically). Examples: `examples/library/input_manager.ludic`, `examples/library/input_auto.ludic`.
|
||||
- **feat**: Namespace block form (#76) — `namespace Name { export function foo(…) … internal function bar(…) … }` declares a `Name.*` namespace once and controls its public surface declaratively, instead of annotating every function with `@Namespace(Name)` one at a time. Inside the block each `function short(…)` is emitted as `namelower_short`; an `export` function (the default) is callable as `Name.short(…)`, while an `internal` function is a private helper — emitted and callable by its short name from siblings in the block (calls are rewritten to the emitted name), but not part of the `Name.*` surface (`Name.internalOne()` is a compile error). It is the block sugar for the per-function `@Namespace` annotation, so a package's public API reads at a glance. A namespace declared the old per-function way is unchanged. Example: `examples/library/namespace_block.ludic`.
|
||||
- **feat**: Namespaced spritesheet / atlas API (#81). Sprite loading was a bare `png_load("floor0.png")` — one file per 16x16 sprite, with no way to load one sheet and address a cell by grid coords or name. Adds a `Sprite.*` / `Assets.*` runtime (over the variable-size image loader, so a cell is a sub-rect of the kept image and is **not** restricted to the 16x16 sprite table): `Sprite.sheet(path, cellw, cellh)` -> handle, `Sprite.cell(sheet, col, row)` and `Sprite.cell_span(sheet, col, row, cols, rows)` -> id (a sprite may span more than one cell — a tall character, a wide object), `Sprite.define(name, …)` / `Sprite.named(name)` to name and look up a cell, `Sprite.draw` / `Sprite.draw_scaled` (through the camera / zoom / clip, like `Screen.sprite`), `Sprite.width` / `height`, and `Assets.image(path)` / `Assets.load` / `Assets.get(name)`. Spliced on demand; a program that uses neither compiles byte-identically. Example: `examples/library/atlas.ludic`.
|
||||
- **feat**: Optional world boundaries (#84) — declare a single `Bounds` config entity (a rect `x, y, w, h` plus a policy, shipped from **ludic.core**) and the engine-owned world-bounds system keeps every moving `Body` inside the play area each frame, so a game no longer hand-clamps `Position`. Four policies: `0` clamp (walls), `1` wrap (toroidal), `2` bounce (clamp + flip the Body velocity on the axis that hit), `3` kill (despawn a body fully outside). It reads each body's `Collider` size so the whole box stays inside; off by default (no `Bounds` entity = open world). Registered on the engine-system registry for LateUpdate (after movement integrates) and spliced only when a game declares `Bounds`, so a game that never does compiles byte-identically. Also adds `World.despawn(entity)` — the reflective, by-id form of the `despawn` statement (runs `@OnDespawn` + frees the slot), which the kill policy uses and any system can call. Example: `examples/library/world_bounds.ludic`.
|
||||
- **feat**: Package manager (#63) — `x add` / `x get` / `x update` / `x verify` / `x vendor` bring third-party packages to Ludic with no new infrastructure. Dependencies are named by their git import path (URL-as-identity, no registry — a `git tag vX.Y.Z` is publishing), resolved by Go-style Minimum Version Selection, fetched into a content-addressed global store (`~/.ludic/store`, keyed by a file-content hash) and linked into each project under `ludic_modules/`. A `package.ludic` manifest declares dependencies, the provided `Foo.*` namespace(s), the kind (source or prebuilt) and, for prebuilt libs, the shipped targets; `package.lock.ludic` pins the resolved versions and content hashes for reproducible, verifiable builds. A source package's Ludic compiles into the consumer via a new module-root import fallback in the compiler (`import "git.workshopsoft.io/user/pkg/foo.ludic"` resolves against `$LUDIC_MODULES`, default `ludic_modules/`), so a package registers a namespace the same way the built-in stdlib does. Namespace collisions and missing prebuilt targets are hard errors. Existing programs compile byte-for-byte identically; the C-free bootstrap fixpoint is untouched. See docs/PACKAGES.md.
|
||||
- **feat**: Package-declarable namespaces & engine systems (#62) — the two hooks that made `Foo.*` stdlib namespaces and engine-owned systems compiler-hardcoded are now data-driven registries, so a package registers them with no compiler edit. `@Namespace(Name)` on a function opens a `Name.method(…)` namespace that dispatches to the bare `name_method` through the same generic path the built-in namespaces use (applied only after them, so it never shadows a core one). `@EngineSystem(Component, Phase)` registers an engine-owned system the frame loop runs each phase when the component is present — the package-declarable form of the built-in SpriteAnim/Motion/Light2D systems, reading components by name through the reflection ABI so an unused registration is byte-identical. Both annotations are keyword-free. The core stdlib keeps its optimized codegen (byte-identical output; the C-free bootstrap fixpoint is untouched) and packages ride the generic registry alongside it. This completes the packaging prerequisite for shipping gameplay-controller libraries (#58–#61) as real packages. See docs/PACKAGES.md.
|
||||
- **feat**: Prebuilt binary packages (#64) — a package can now ship a compiled artifact whose exported functions, systems and components a consumer uses without the source, over the stable reflection C-ABI. `x build-lib <module.ludic>` compiles a package's module to a per-target native dylib (`lib/<target>/`); a `kind prebuilt` dependency is fetched and linked like any other, and `x link-flags` prints the clang flags to link the module dylibs into a game (or `x app` does it in-repo). A module registers its dynamic components (`world_register_prop`) and its `@System(Phase)` functions with the host at load through a constructor, and the host dispatches every registered system each frame — the systems analogue of dynamic components. Binary packages are native-only and second-class ECS by design (source packages remain the portable, first-class, deterministic path); missing a build target is a hard error. See docs/PACKAGES.md.
|
||||
- **feat**: The engine runtime ships with the toolchain, not the project (#75). The compiler auto-splices `runtime/native/*` for any ECS game; a `runtime/...` import that is not found relative to the build is now resolved from the install root **`$LUDIC_HOME`** (default: the compiler binary's directory — where the platform `.ll` files already come from) *before* the package module root. So an external game that consumes the `ludic.*` packages no longer has to copy or symlink the engine runtime into its `ludic_modules/`; that directory holds only third-party packages, and the runtime is part of the toolchain install. In-repo builds are byte-identical (the runtime still resolves locally there, so the `$LUDIC_HOME` fallback never fires and the C-free bootstrap fixpoint is untouched).
|
||||
- **fix**: **Correct the element type of a slice indexed by a member-access expression.** `emit_index_addr` set the global `g_addr_ty` to the slice's element type *before* evaluating the index expression, so an index that was itself a struct-field access (`slice[obj.field]`) overwrote it — the load then came back typed as the field, and a following field access failed with "member access on non-aggregate". The slice branch now sets `g_addr_ty` last, matching the raw-pointer branches. Also de-duplicate the `@strcmp` declaration (centralised in the head prelude) so a program that pulls in both the world table and the filesystem prelude links.
|
||||
- **fix**: Input.key_pressed / key_released edges now fire (#87). In a frame-loop game the edges never triggered: the loop (since #83) commits the device layer once per frame via `input_drive`, but a game that *also* called `Input.poll` by hand committed a second time in the same frame, and `input_device_commit` copies `in_held` into `in_prev` at the top of every commit — so the second commit left `in_prev == in_held` and `key_pressed` (`held && !prev`) / `key_released` could never see a transition. Fixed with an `in_have_frame_driver` flag: the loop's `input_drive` sets it, and a manual `Input.poll` under the loop becomes a no-op that returns the frame's key instead of re-committing. An entry-driven harness has no loop, so the flag stays false and each `Input.poll` still commits a frame of input as before (record/replay and the #50 device tests are unchanged). Example: `examples/library/input_edge.ludic` (press edge on the down frame, release edge on the up frame).
|
||||
- **fix**: Windowed games no longer force-quit on Esc or 'q' (#88). The macOS platform layer (`runtime/native/cocoa.ll` `win_poll`) used to hard-code Escape (keycode 53) and the character 'q' as *quit* — storing `W_running = 0` so a shipped windowed game died the instant a player pressed Esc (a universal pause key) or typed 'q'. Those dev-loop conveniences are removed for windowed builds: **Escape is delivered to the game as key 27** and **'q' is an ordinary key**, consistently across both the single per-frame key (`@W_key`) and the `#50` held-key set (`ev_keyval` now maps Escape→27, not 'q'). A windowed game now owns Esc/pause and shuts down via `quit()` or the window close button (which still ends the run). The headless test driver (`rt_poll` in `core.ludic`) keeps its own `'q'` = quit for scripted golden runs, so nothing headless changes. Also stops forwarding consumed key events to `-sendEvent:`, which was triggering AppKit's system "funk" beep on every keystroke.
|
||||
### Features
|
||||
|
||||
- **Tiled map support (#67–#74)** — load and draw [Tiled](https://www.mapeditor.org/) maps (TMX/TSX/TX and TMJ/TSJ/TJ), the design record from #66.
|
||||
|
||||
- **P0 parsing primitives (#67)** — a minimal pure-Ludic XML reader (`Xml.*`) for the element/attribute/CDATA subset TMX/TSX/TX use; standard base64 decode/encode (`Base64.*`, RFC 4648), whose decoder ignores the whitespace Tiled wraps into `<data>`; and gzip framing (`z_gunzip`, RFC 1952) wrapping the existing DEFLATE inflater. zlib and the JSON reader already shipped. A curated, attributed golden corpus lands under `assets/tiled-fixtures/`.
|
||||
- **P0.5 TMX/TSX reader (#68)** — `Tiled.read` / `Tiled.read_tsx` map the native XML formats onto the *same* intermediate the JSON path produces — a `Value` tree in Tiled's JSON schema, with every tile layer's data decoded to a dense GID list (CSV, base64, base64+zlib, base64+gzip). A CSV `.tmx` and a base64+zlib `.tmj` of the same map read structurally identically; the in-repo Kenney `sampleMap.tmx` + external `sampleSheet.tsx` load with no manual JSON re-export.
|
||||
- **P1 core load + render (#69)** — the runtime `rt_tmap` model (heap-allocated to `w·h`, lifting the old `96×64` cap), the GID resolver (`Tiled.resolve` → tileset / local id / H·V·D flips), image-backed rendering (`Tiled.draw`, flips applied at blit), and the legacy-tilemap compatibility projection so `Grid.*`/`Path.*`/`esys_move` keep working. `Tiled.load` reads either format, resolves external tilesets + images, and auto-projects a `collision` layer. The Kenney sample loads and renders pixel-identically from `.tmx` and `.tmj`; the `grid` and `physics_tiles` demos now run off a loaded map.
|
||||
- **P2 collision & grid (#70)** — normalise three collision sources into the byte tilemap `esys_move`/`Grid.*`/`Path.*` read, in the design's priority order: per-tile `<objectgroup>` hitboxes, the `solid`/`oneway`/`trigger` property convention (`Tiled.collision_kind`/`Tiled.tile_shapes`), and the designated collision layer (`Tiled.project`, any non-zero GID solid) — or drive collision from a visual layer's per-tile metadata alone (`Tiled.collide`). The property convention and the collision-layer fallback produce the same feed; `Path.a_star` over a loaded map matches the hand-authored baseline.
|
||||
- **P3 animated tiles + tile objects (#71)** — a tileset `<animation>` advances deterministically as a pure function of the fixed 60/s engine frame clock (`Tiled.frame_gid`/`Tiled.animated`), so an animated GID resolves at draw to the current frame's GID with its flip flags preserved and reproduces frame-for-frame across runs; `Tiled.draw_anim` draws a map with animations advanced. Object-layer entries with a `gid` render the tile image (with their own flips), bottom-anchored, as placeable sprites.
|
||||
- **P4 objects, properties, templates, spawning (#72)** — all object shapes (rectangle / ellipse / point / polygon / polyline / text) and custom properties parse and are queryable (`Tiled.object`, `Tiled.object_shape`, `Tiled.prop`/`Tiled.prop_int`/`Tiled.prop_type`); class properties resolve their defaults against a project custom-type table (`Tiled.load_types` over `objecttypes.xml`); template instances inherit their `.tx`/`.tj` template's fields; and an object maps onto Ludic components on demand (`Tiled.spawn`/`Tiled.spawn_layer`, off by default, via the reflection ABI).
|
||||
- **P5 breadth (#73)** — image layers (parallax + repeat) and group layers (flattened, with recursive offset/opacity/tint/visible; `Tiled.layer_kind`/`Tiled.layer_offsetx`/`Tiled.layer_tint`); the isometric / staggered / hexagonal orientation coordinate transforms (`Tiled.cell_x`/`Tiled.cell_y`, driving the tile draw so cells land at the correct screen coords); and Wang-set GID resolution through the standard resolver (the terrain-corner authoring concept is editor-side and ignored).
|
||||
- **P6 scale (#74)** — infinite/chunked maps: `<chunk>` (TMX) and JSON `chunks[]` decode and flatten into the dense layer; `.world` stitching (`Tiled.world`/`Tiled.world_count`/`Tiled.world_map`) lists member maps at their offsets; and a self-contained pure-Ludic **zstd** decompressor (`z_zstd`, RFC 8878) for base64+zstd layers — frame + raw/RLE/compressed blocks, raw/RLE/direct-weight-Huffman literals, and the full FSE sequence path — decoding the low-entropy GID streams a tilemap produces (a high-entropy FSE-compressed-Huffman-weights block fails cleanly with -1 rather than emitting wrong bytes).
|
||||
- Builtin NPC AI (#61) — the source package **ludic.npcai**, a perception → decision → action stack that plugs into the other controllers instead of re-implementing movement. The AI never moves a body directly: it writes the SAME intent fields the player controllers read (`want_x`/`want_y`/`want_fire`, `want_jump`), so an enemy gunner reuses the shooter's weapon/projectile/auto-aim systems verbatim (set the body's `TopDown.aim_mode = 3`) and a companion reuses the mover — friendly vs enemy is faction + goal, not different code. **Perception** (`Vision` + `Memory`, throttled `esys_perception` with faction filtering and optional `Grid` line-of-sight) remembers the nearest hostile and emits `TargetSpotted`/`TargetLost`. **Decision** offers three models writing one `Brain` intent — a finite-state machine (patrol/chase/attack/flee), a utility scorer, and a canonical behaviour tree — each decision veto-able via `cancellable DecisionMade`. **Steering** adds Reynolds flocking (separate/cohere), and a `Follower` component gives companion stances. Reuses ludic.gameplay Faction (who is hostile) + Stats (hp for flee). Fully deterministic: perception + replan are frame-throttled and fixed-order. Example: `examples/games/npcai_demo.ludic` — one enemy perceives, chases and shoots a target through the shooter controller, flees at low hp under the utility model, and a companion follows its leader.
|
||||
- Builtin Platformer controller (#58) — the reference implementation of the six-lever extensibility contract, shipped as the source package **ludic.platformer**. Movement feel is all defaulted POD data (`Platformer { move_speed, jump_height, apex_frames, fall_gravity_mul, coyote_frames, jump_buffer_frames, air_jumps, policy, … }`); the controller is decomposed into small engine-owned sub-systems — input (Input phase), move/gravity/jump (FixedUpdate, before the shared `esys_move` sweep) and animation-state (LateUpdate, after it) — each independently switch-off-able with `disable system <fn>`. It owns movement *policy* only and reuses the engine Body/Collider swept-AABB collision. Jump feel derives gravity + impulse from height/apex, with coyote time, jump buffering, variable jump height and multi-jump; every jump decision emits a `cancellable JumpRequested` / `JumpPerformed` / `Landed` / `StateChanged` event, and a gravity `policy` enum (asymmetric / symmetric / floaty) is the formula hook. Ships the opt-in game-loop layer too (`scaffolding.ludic`): moving & crumbling platform blocks with rider carry, collectibles + `Score`, springs, hazards + a light `Life`/i-frames model, and checkpoint/goal triggers. Deterministic integer Q16.16 throughout. Examples: `examples/games/platformer_demo.ludic`, `examples/games/platformer_scaffolding.ludic`. Also fixes a latent codegen bug in `ludic_sweep_entity` (an SSA register name collided once a program declared ≥11 events).
|
||||
- Builtin RPG systems suite (#59) — the source package **ludic.rpg**, seven independently-usable modules on the six-lever contract, with name-keyed data registries so a game or mod adds content with zero code. **A Movement** — one `Mover` with a `mode` selector (grid / free / grid-tween) and 4/8-axis, tilemap walkability, and `cancellable MoveRequested` (locked doors/ice) / `TileEntered` (encounters) / `Interacted` (the action-button raycast). **B Inventory** — a name-keyed item registry, per-owner counts, gold, `ItemUse` veto, and `Equipment` whose bonuses flow through the gameplay Stats modifier stack. **C Crafting** — a data-driven recipe + ingredient registry; `Craft.can`/`Craft.make` consume from the inventory. **D Quests** — quests + objectives whose progress is driven by `Quest.notify` (route any gameplay signal in), auto-completing when met, plus a global flag store for branching. **E Dialog** — an Ink/Yarn-style graph registry (nodes + choices) with a per-speaker `Dialog` component and `cancellable DialogChoice` for skill-check gating. **F Puzzles** — Sokoban `Pushable` + a switch / pressure-plate / gate signal graph (logic puzzles with no code). **G Status** — over-time poison/regen effects routed through the shared Combat pipeline. Everything is integer-deterministic, so save/load (world_save) and rollback hold. Example: `examples/games/rpg_demo.ludic` (21 self-checks across all seven modules).
|
||||
- Builtin top-down Shooter controller (#60) — the source package **ludic.shooter**, conforming to the six-lever contract and built on the engine Body/Collider + ludic.gameplay Faction/Combat/Stats. `TopDown` decouples movement from aim (`aim_mode`: mouse / right-stick / move-direction / nearest-enemy auto-aim, with a `turn_rate` for tank-style rotation). Weapons are a name-keyed **registry** (`Weapon.def("shotgun", …)` — add a gun with zero code) with per-weapon fire-rate, damage, speed, spread, pellet count, pattern (single / spread cone / ring / spiral), plus data-driven pierce (`Weapon.set_pierce`) and homing (`Weapon.set_homing`); `esys_weapon` reads a `want_fire` intent so the **same** weapon fires for a player (input) and an NPC (AI). `Projectile` + `esys_projectile` is a self-contained deterministic pool: integrate, TTL, faction-filtered hit through `Combat.damage`, pierce, and homing that curves onto the nearest enemy — every step observable via `ProjectileSpawned` (mutable/veto) / `ProjectileHit` / `ProjectileExpired`. A budgeted `Spawner` wave director emits `SpawnRequested` / `WaveCleared`. Example: `examples/games/shooter_demo.ludic` (11 self-checks: movement, aim, faction damage, friendly-fire immunity, spread, kill, ring, homing, waves).
|
||||
- Canonical engine-ABI components (#77) — the shared `Position` / `Body` / `Collider` / `Solids` bundles the engine-owned movement system (`esys_move`, #65) reads by name are now shipped from a base source package, **ludic.core**, instead of being re-declared by hand in every game and example. A game `import "ludic.core/components.ludic"` and the engine moves and collides its entities for free; extend by *composition* (attach your own components on the same model). AOT means the properties compile straight into the consumer's compile-time ECS with no ABI seam, and everything stays integer + Q16.16 deterministic (lockstep / replay / `world_save` hold). Example: `examples/library/core_components.ludic`.
|
||||
- Cursor capture (#89) — `Input.cursor_mode(mode)` hides / locks / confines the OS mouse for a windowed game: `0` normal (visible, free), `1` hidden (hide the OS cursor while focused so a game draws its own reticle), `2` locked (hidden + dissociated — the mouse feeds *relative* motion through `Input.mouse_dx/dy`, and `Input.mouse_x/y` becomes a clamped virtual cursor, the FPS / twin-stick aim mode), `3` confined (dissociated but visible; the mouse cannot leave the window). The platform auto-releases (shows + reconnects the cursor) while the window is not key (Cmd-Tab) and on close, so the cursor is never left captured. Adds the native macOS implementation in `cocoa.ll` (`[NSCursor hide]/[unhide]`, ref-counted and toggled only on change; `CGAssociateMouseAndMouseCursorPosition`; `CGGetLastMouseDelta` for the relative virtual cursor) behind a new `win_cursor_mode` intrinsic; headless / non-windowed it is a no-op (DCE'd). Example: `examples/library/cursor_capture.ludic`.
|
||||
- Declarative Render clear + present (#86) — `@ClearColor(0xRRGGBB)` makes the engine own the per-frame clear and flip: at the top of the Render phase it clears the framebuffer to the declared colour, and after the Render handlers run it presents the frame, so a game's Render handler no longer repeats `Screen.clear(color)` / `Screen.show()` and the clear colour is configured *declaratively* rather than in the handler body. Opt-in and backward-compatible: a program with no `@ClearColor` is byte-for-byte identical (it clears/presents itself, or the light system owns the present). Example: `examples/library/clear_color.ludic`.
|
||||
- Deterministic camera zoom (#78) — `Camera.zoom(scale)` scales the whole view about the screen centre by a Q16.16 factor (`1.0` = none, `2.0` = 2x in, `0.5` = out). It rides on the same two framebuffer chokepoints (`rt_put_px` / `rt_fill_rect`) that already carry the camera offset, so it composes with `Camera.set`/`follow`/`shake`, and it is a *render-time* transform — the world coordinate types stay integer pixels + Q16.16 velocity, so lockstep, replay and `world_save` are untouched, and the zoom itself is deterministic. Gated by an internal `rt_cam_zoomed` flag so a game that never zooms renders byte-for-byte identically (golden renders unchanged); `Camera.zoom(1.0)` turns it back off. This is the concrete outcome of the #78 position-types investigation (`docs/RFC-POSITION-TYPES.md`), which rejected hardware floats for the deterministic coordinate core and identified zoom as the one genuinely-missing render feature. Example: `examples/library/camera_zoom.ludic` (pixel-readback verified).
|
||||
- Directional int input (#79) — `Input.axis_i(neg, pos) -> int` returns a -1/0/1 movement intent (`+1` positive key held, `-1` negative, `0` neither or both) read from the multi-key device set, so turning WASD into movement no longer needs the `ki(key_down('d')) - ki(key_down('a'))` bool-to-int glue and feeds an int mover directly: `dx = Input.axis_i('a', 'd')`, `dy = Input.axis_i('w', 's')`. Complements `Input.axis` (fixed) / `Input.vector` (normalized). Example: `examples/library/input_movement.ludic`.
|
||||
- Engine sprite-render system (#85) — the engine already auto-*ticks* SpriteAnim and Motion; it now auto-*draws* too. Declare a `Sprite` component (id + optional offx/offy/scale/flip/tint/hidden, shipped from **ludic.core**) on an entity with a `Position` and the engine draws it each Render frame — no hand-written Render handler querying positions and calling `draw_sprite` per entity, and no hand animation (when the entity also carries `SpriteAnim`, the current frame is added to the base id). Registered on the compile-time engine-system registry for the Render phase and spliced only when a game declares `Sprite`, so a game that never declares it compiles byte-identically; a game wanting a custom draw omits `Sprite` (or `disable system esys_sprite`). Also **deprecates the bare `draw_sprite` / `draw_sprite_scaled` globals** in favour of the namespaced `Screen.sprite` / `Screen.sprite_scaled`: a direct bare call now emits a one-time compile-time deprecation note (the bare form still lowers, since `Screen.sprite` uses it), and the in-repo `chronorift` demo is migrated to the namespaced calls. Example: `examples/library/sprite_render.ludic`.
|
||||
- Entity-pool stats (#80). Ludic's ECS is already pool-based: the allocator recycles freed entity slots through a freelist (a `despawn`ed slot is reused by the next `spawn` before any new slot is taken), and component storage is fixed per-entity arrays — so spawning and despawning many entities per frame (bullet-hell / horde) does **no per-spawn heap allocation** and cannot fragment. Exposes that with a `Pool.*` namespace so a game can watch the reuse and budget against the cap: `Pool.live()` (entities alive now), `Pool.free()` (freed slots waiting to be reused), `Pool.reserved()` (high-water — slots ever allocated; stays flat across a steady spawn/despawn loop, the proof that slots are pooled not reallocated), and `Pool.capacity()` (the fixed entity cap). Zero-cost — they read the existing allocator counters inline. Example: `examples/library/pool.ludic`.
|
||||
- Gameplay-controller foundation (#57) — the shared, cross-genre building blocks the builtin controllers stand on, shipped as the source package **ludic.gameplay**: a deterministic `Cooldown` frame timer (engine-ticked), a `Stats` attribute bundle with an unbounded timed **modifier stack** (`Stats.total` computes base+flat then percent on demand; expired modifiers self-despawn), a `Faction` friend/enemy/neutral relationship table (same-id-friendly / different-hostile by default), and a `Combat` damage pipeline whose `cancellable DamageAboutToApply` hook lets a game veto a hit *or rewrite the amount* (`Combat.set_amount`) and which emits `Damaged`/`Died`/`Healed`. Everything is integer-only so lockstep, replay and `world_save` snapshots hold. Also adds extensibility **lever 5** to the language: `disable system <esys_fn>` drops exactly one engine-owned system's tick at compile time, so a game can carry a well-known component but tick it with its own handler (byte-identical when nothing is disabled; the C-free bootstrap fixpoint is untouched). Example: `examples/library/gameplay_foundation.ludic`.
|
||||
- Incremental asset preloading (#82). Assets used to load synchronously inside Boot/Start (`png_load`, `Audio.load`), stalling the first frame(s) as content grows, with no built-in loading phase. Adds an `Assets.*` preload queue: `Assets.enqueue(name, path)` queues a named image without loading it, `Assets.pump(max)` loads up to `max` queued assets per frame (returning how many it loaded), and `Assets.total` / `loaded` / `ready` / `progress` (0..100) drive a progress bar. A loading scene pumps a few assets per frame, draws `Assets.progress()`, and `become`s the play scene once `Assets.ready()`, so the game shows a responsive loading screen and only enters play once content is ready — the deterministic, no-threads form of async preloading (the work is spread across frames instead of stalling one, and the same enqueue+pump order loads identically every run). Loaded assets are reachable by name via `Assets.get` / `Sprite.named`. Builds on the #81 atlas. Example: `examples/library/preload.ludic`.
|
||||
- Input Manager + automatic device-layer drive (#83). The generated frame loop now commits the input device layer itself — when a game uses any Input action-map / device method it calls `input_poll` each frame (reading the live key, recording/replaying, and rebuilding the held-key/mouse/gamepad state), so `Input.active` / `Input.key_down` / the mouse and pads read live **without the game calling `Input.poll` by hand** (previously the loop fed only `Input.key`, and the device layer read empty unless the game polled at the top of its Input phase). A game that uses no Input runtime keeps the plain `rt_poll` path, byte-identical. Adds the Input-Manager API on top: `Input.action(name, key)` ships a **default** binding (kept if already bound, so a player's `Input.rebind` or a loaded key-map is not clobbered); `Input.bind_pad(name, button)` makes an action **device-agnostic** (fires from keyboard *or* gamepad); and `Input.active` / `Input.just_pressed` / `Input.just_released` read the whole multi-key device layer with clean on-press / on-release edges (the deterministic, dispatch-free equivalent of event handlers — a handler polls the edge and reacts, so a replay fires identically). Examples: `examples/library/input_manager.ludic`, `examples/library/input_auto.ludic`.
|
||||
- Namespace block form (#76) — `namespace Name { export function foo(…) … internal function bar(…) … }` declares a `Name.*` namespace once and controls its public surface declaratively, instead of annotating every function with `@Namespace(Name)` one at a time. Inside the block each `function short(…)` is emitted as `namelower_short`; an `export` function (the default) is callable as `Name.short(…)`, while an `internal` function is a private helper — emitted and callable by its short name from siblings in the block (calls are rewritten to the emitted name), but not part of the `Name.*` surface (`Name.internalOne()` is a compile error). It is the block sugar for the per-function `@Namespace` annotation, so a package's public API reads at a glance. A namespace declared the old per-function way is unchanged. Example: `examples/library/namespace_block.ludic`.
|
||||
- Namespaced spritesheet / atlas API (#81). Sprite loading was a bare `png_load("floor0.png")` — one file per 16x16 sprite, with no way to load one sheet and address a cell by grid coords or name. Adds a `Sprite.*` / `Assets.*` runtime (over the variable-size image loader, so a cell is a sub-rect of the kept image and is **not** restricted to the 16x16 sprite table): `Sprite.sheet(path, cellw, cellh)` -> handle, `Sprite.cell(sheet, col, row)` and `Sprite.cell_span(sheet, col, row, cols, rows)` -> id (a sprite may span more than one cell — a tall character, a wide object), `Sprite.define(name, …)` / `Sprite.named(name)` to name and look up a cell, `Sprite.draw` / `Sprite.draw_scaled` (through the camera / zoom / clip, like `Screen.sprite`), `Sprite.width` / `height`, and `Assets.image(path)` / `Assets.load` / `Assets.get(name)`. Spliced on demand; a program that uses neither compiles byte-identically. Example: `examples/library/atlas.ludic`.
|
||||
- Optional world boundaries (#84) — declare a single `Bounds` config entity (a rect `x, y, w, h` plus a policy, shipped from **ludic.core**) and the engine-owned world-bounds system keeps every moving `Body` inside the play area each frame, so a game no longer hand-clamps `Position`. Four policies: `0` clamp (walls), `1` wrap (toroidal), `2` bounce (clamp + flip the Body velocity on the axis that hit), `3` kill (despawn a body fully outside). It reads each body's `Collider` size so the whole box stays inside; off by default (no `Bounds` entity = open world). Registered on the engine-system registry for LateUpdate (after movement integrates) and spliced only when a game declares `Bounds`, so a game that never does compiles byte-identically. Also adds `World.despawn(entity)` — the reflective, by-id form of the `despawn` statement (runs `@OnDespawn` + frees the slot), which the kill policy uses and any system can call. Example: `examples/library/world_bounds.ludic`.
|
||||
- Package manager (#63) — `x add` / `x get` / `x update` / `x verify` / `x vendor` bring third-party packages to Ludic with no new infrastructure. Dependencies are named by their git import path (URL-as-identity, no registry — a `git tag vX.Y.Z` is publishing), resolved by Go-style Minimum Version Selection, fetched into a content-addressed global store (`~/.ludic/store`, keyed by a file-content hash) and linked into each project under `ludic_modules/`. A `package.ludic` manifest declares dependencies, the provided `Foo.*` namespace(s), the kind (source or prebuilt) and, for prebuilt libs, the shipped targets; `package.lock.ludic` pins the resolved versions and content hashes for reproducible, verifiable builds. A source package's Ludic compiles into the consumer via a new module-root import fallback in the compiler (`import "git.workshopsoft.io/user/pkg/foo.ludic"` resolves against `$LUDIC_MODULES`, default `ludic_modules/`), so a package registers a namespace the same way the built-in stdlib does. Namespace collisions and missing prebuilt targets are hard errors. Existing programs compile byte-for-byte identically; the C-free bootstrap fixpoint is untouched. See docs/PACKAGES.md.
|
||||
- Package-declarable namespaces & engine systems (#62) — the two hooks that made `Foo.*` stdlib namespaces and engine-owned systems compiler-hardcoded are now data-driven registries, so a package registers them with no compiler edit. `@Namespace(Name)` on a function opens a `Name.method(…)` namespace that dispatches to the bare `name_method` through the same generic path the built-in namespaces use (applied only after them, so it never shadows a core one). `@EngineSystem(Component, Phase)` registers an engine-owned system the frame loop runs each phase when the component is present — the package-declarable form of the built-in SpriteAnim/Motion/Light2D systems, reading components by name through the reflection ABI so an unused registration is byte-identical. Both annotations are keyword-free. The core stdlib keeps its optimized codegen (byte-identical output; the C-free bootstrap fixpoint is untouched) and packages ride the generic registry alongside it. This completes the packaging prerequisite for shipping gameplay-controller libraries (#58–#61) as real packages. See docs/PACKAGES.md.
|
||||
- Prebuilt binary packages (#64) — a package can now ship a compiled artifact whose exported functions, systems and components a consumer uses without the source, over the stable reflection C-ABI. `x build-lib <module.ludic>` compiles a package's module to a per-target native dylib (`lib/<target>/`); a `kind prebuilt` dependency is fetched and linked like any other, and `x link-flags` prints the clang flags to link the module dylibs into a game (or `x app` does it in-repo). A module registers its dynamic components (`world_register_prop`) and its `@System(Phase)` functions with the host at load through a constructor, and the host dispatches every registered system each frame — the systems analogue of dynamic components. Binary packages are native-only and second-class ECS by design (source packages remain the portable, first-class, deterministic path); missing a build target is a hard error. See docs/PACKAGES.md.
|
||||
- The engine runtime ships with the toolchain, not the project (#75). The compiler auto-splices `runtime/native/*` for any ECS game; a `runtime/...` import that is not found relative to the build is now resolved from the install root **`$LUDIC_HOME`** (default: the compiler binary's directory — where the platform `.ll` files already come from) *before* the package module root. So an external game that consumes the `ludic.*` packages no longer has to copy or symlink the engine runtime into its `ludic_modules/`; that directory holds only third-party packages, and the runtime is part of the toolchain install. In-repo builds are byte-identical (the runtime still resolves locally there, so the `$LUDIC_HOME` fallback never fires and the C-free bootstrap fixpoint is untouched).
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Correct the element type of a slice indexed by a member-access expression.** `emit_index_addr` set the global `g_addr_ty` to the slice's element type *before* evaluating the index expression, so an index that was itself a struct-field access (`slice[obj.field]`) overwrote it — the load then came back typed as the field, and a following field access failed with "member access on non-aggregate". The slice branch now sets `g_addr_ty` last, matching the raw-pointer branches. Also de-duplicate the `@strcmp` declaration (centralised in the head prelude) so a program that pulls in both the world table and the filesystem prelude links.
|
||||
- Input.key_pressed / key_released edges now fire (#87). In a frame-loop game the edges never triggered: the loop (since #83) commits the device layer once per frame via `input_drive`, but a game that *also* called `Input.poll` by hand committed a second time in the same frame, and `input_device_commit` copies `in_held` into `in_prev` at the top of every commit — so the second commit left `in_prev == in_held` and `key_pressed` (`held && !prev`) / `key_released` could never see a transition. Fixed with an `in_have_frame_driver` flag: the loop's `input_drive` sets it, and a manual `Input.poll` under the loop becomes a no-op that returns the frame's key instead of re-committing. An entry-driven harness has no loop, so the flag stays false and each `Input.poll` still commits a frame of input as before (record/replay and the #50 device tests are unchanged). Example: `examples/library/input_edge.ludic` (press edge on the down frame, release edge on the up frame).
|
||||
- Windowed games no longer force-quit on Esc or 'q' (#88). The macOS platform layer (`runtime/native/cocoa.ll` `win_poll`) used to hard-code Escape (keycode 53) and the character 'q' as *quit* — storing `W_running = 0` so a shipped windowed game died the instant a player pressed Esc (a universal pause key) or typed 'q'. Those dev-loop conveniences are removed for windowed builds: **Escape is delivered to the game as key 27** and **'q' is an ordinary key**, consistently across both the single per-frame key (`@W_key`) and the `#50` held-key set (`ev_keyval` now maps Escape→27, not 'q'). A windowed game now owns Esc/pause and shuts down via `quit()` or the window close button (which still ends the run). The headless test driver (`rt_poll` in `core.ludic`) keeps its own `'q'` = quit for scripted golden runs, so nothing headless changes. Also stops forwarding consumed key events to `-sendEvent:`, which was triggering AppKit's system "funk" beep on every keystroke.
|
||||
|
||||
## v0.2.0 — 2026-09-01
|
||||
|
||||
|
|
@ -85,9 +103,14 @@ byte-for-byte identically, and the C-free bootstrap fixpoint is untouched.
|
|||
|
||||
## v0.1.0 — 2026-08-30
|
||||
|
||||
- **ci**: Continuous integration — Forgejo Actions workflows build the toolchain from the seed, run the regression + editor suites, assert the C-free bootstrap fixpoint, and lint commit messages on every push and pull request.
|
||||
- **feat**: Editor tooling — `ludic-fmt` (formatter) and `ludic-lsp` (language server), plus VS Code and JetBrains integrations, all built by the toolchain.
|
||||
- **feat**: Namespaced standard library — Math, Text, List, Random, Time, Screen, Color, Ease, Collide, Memory, Vector, DateTime/Date/Duration/Clock, Unicode, Os, Fs/Path/Mime, Log, Noise, Hash, Crypto and Uuid, each deterministic where a game needs it.
|
||||
- **feat**: Native 2D backend — an ECS core with a deterministic fixed-point (Q16.16) runtime, a windowed Cocoa target on macOS and a headless PPM renderer that runs anywhere.
|
||||
- **feat**: Self-hosted, C-free toolchain — the compiler, runtime, task runner and editor tools are all written in Ludic and built from a checked-in LLVM-IR seed with clang alone; `x bootstrap-cfree` proves the compiler rebuilds itself byte-for-byte.
|
||||
- **feat**: Versioning and releases — SemVer with `ludicc --version`, a changeset-driven `CHANGELOG.md`, and `x release` to bump, tag, and publish a Forgejo release with source and toolchain artifacts.
|
||||
### Features
|
||||
|
||||
- Editor tooling — `ludic-fmt` (formatter) and `ludic-lsp` (language server), plus VS Code and JetBrains integrations, all built by the toolchain.
|
||||
- Namespaced standard library — Math, Text, List, Random, Time, Screen, Color, Ease, Collide, Memory, Vector, DateTime/Date/Duration/Clock, Unicode, Os, Fs/Path/Mime, Log, Noise, Hash, Crypto and Uuid, each deterministic where a game needs it.
|
||||
- Native 2D backend — an ECS core with a deterministic fixed-point (Q16.16) runtime, a windowed Cocoa target on macOS and a headless PPM renderer that runs anywhere.
|
||||
- Self-hosted, C-free toolchain — the compiler, runtime, task runner and editor tools are all written in Ludic and built from a checked-in LLVM-IR seed with clang alone; `x bootstrap-cfree` proves the compiler rebuilds itself byte-for-byte.
|
||||
- Versioning and releases — SemVer with `ludicc --version`, a changeset-driven `CHANGELOG.md`, and `x release` to bump, tag, and publish a Forgejo release with source and toolchain artifacts.
|
||||
|
||||
### CI
|
||||
|
||||
- Continuous integration — Forgejo Actions workflows build the toolchain from the seed, run the regression + editor suites, assert the C-free bootstrap fixpoint, and lint commit messages on every push and pull request.
|
||||
|
|
|
|||
|
|
@ -16,10 +16,36 @@ the changelog. Markdown is fine.
|
|||
- `bump:` — `major`, `minor`, or `patch` (SemVer). The release version is bumped
|
||||
by the **highest** level among the pending changesets (unless `x release <level>`
|
||||
overrides it).
|
||||
- `type:` — the Conventional Commit type (`feat`, `fix`, `perf`, `docs`, …); it
|
||||
becomes the bold prefix of the changelog bullet.
|
||||
- `type:` — the Conventional Commit type (`feat`, `fix`, `perf`, `docs`, …). It
|
||||
decides which group the change lands in: `feat` → **Features**, `fix` →
|
||||
**Fixes**, `perf` → **Performance**, and so on, in that order. A type with no
|
||||
known heading gets one named after itself.
|
||||
|
||||
## Writing the body
|
||||
|
||||
The body is markdown and reaches the changelog as markdown: it becomes one list
|
||||
item, with continuation lines indented to stay inside it. Nested bullets, blank
|
||||
lines between paragraphs and inline code all survive.
|
||||
|
||||
```
|
||||
bump: minor
|
||||
type: feat
|
||||
**Tiled map support** — load and draw Tiled maps.
|
||||
|
||||
- **TMX/TSX** — the XML formats, decoded to the same intermediate as JSON.
|
||||
- **Collision** — the `collision` layer projects onto the engine tilemap.
|
||||
```
|
||||
|
||||
Lead with the thing that changed, not with the mechanism. A reader scanning the
|
||||
release should be able to stop after your first clause.
|
||||
|
||||
## Adding one
|
||||
|
||||
Create a file with a short, unique name, e.g. `changes/regex-namespace.md`. Any
|
||||
filename works except this `README.md`, which the release step always skips.
|
||||
|
||||
Preview how the next release will read before cutting it — this writes nothing:
|
||||
|
||||
```bash
|
||||
x release --dry-run
|
||||
```
|
||||
|
|
|
|||
14
changes/changelog-structure.md
Normal file
14
changes/changelog-structure.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
bump: minor
|
||||
type: fix
|
||||
`x release` keeps a changeset's markdown intact. Bodies used to go through
|
||||
`tr '\n' ' '`, which flattened every multi-line changeset into one paragraph —
|
||||
nested bullets came out as inline `" - "` runs and a release read as a single
|
||||
unbroken wall of text. A section is now grouped by change type (**Features**,
|
||||
**Fixes**, **Performance**, …) with one bullet per changeset and continuation
|
||||
lines indented to stay inside it.
|
||||
|
||||
- `x release --dry-run` renders the next section to stdout and writes nothing,
|
||||
so a release can be read before it is cut.
|
||||
- `x changelog-section <version>` prints one release's section from
|
||||
`CHANGELOG.md`; `x changelog-render` re-renders a section from a directory of
|
||||
changesets. The v0.1.0 and v0.3.0 sections were re-rendered with these.
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
# Bootstrap it once from a clean checkout (the only step Ludic cannot do for
|
||||
# itself, since compiling Ludic needs a compiler):
|
||||
#
|
||||
# clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
|
||||
# mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
|
||||
#
|
||||
# Thereafter `bin/x build` rebuilds the whole toolchain — including bin/x itself.
|
||||
# Always run x from the repository root.
|
||||
|
|
@ -68,8 +68,12 @@ program X {
|
|||
print("")
|
||||
print("release:")
|
||||
print(" x version print the toolchain version (ludicc --version)")
|
||||
print(" x release [major|minor|patch] [--publish]")
|
||||
print(" x release [major|minor|patch] [--dry-run] [--publish]")
|
||||
print(" cut a release: CHANGELOG + VERSION bump + tag (+ Forgejo release)")
|
||||
print(" x publish [vX.Y.Z] publish an already-tagged release (artifacts + notes); what CI runs on a tag push")
|
||||
print(" x changelog-section <ver> print that release's CHANGELOG.md section")
|
||||
print(" x changelog-render <ver> <date> <dir>")
|
||||
print(" render a CHANGELOG section from a directory of changesets")
|
||||
print("")
|
||||
print("self-host internals:")
|
||||
print(" x selfhost-build [ludicc] [out] assemble + compile the self-host compiler")
|
||||
|
|
@ -132,6 +136,9 @@ program X {
|
|||
if (cmd == "test-pkg") { return cmd_test_pkg() }
|
||||
if (cmd == "version") or (cmd == "--version") or (cmd == "-v") { return cmd_version() }
|
||||
if (cmd == "release") { return cmd_release() }
|
||||
if (cmd == "changelog-render") { return cmd_changelog_render() }
|
||||
if (cmd == "changelog-section") { return cmd_changelog_section() }
|
||||
if (cmd == "publish") { return cmd_publish() }
|
||||
if (cmd == "help") or (cmd == "--help") or (cmd == "-h") { usage(); return 0 }
|
||||
|
||||
return -1
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
# bump VERSION, commit, and tag vX.Y.Z. `level` is
|
||||
# major|minor|patch; omitted, it is derived from the
|
||||
# highest `bump:` among the pending changesets.
|
||||
# x release [level] --dry-run render the changelog section to stdout and
|
||||
# stop: nothing is written, committed, tagged or pushed.
|
||||
# x release [level] --publish ...then push main + the tag and create a
|
||||
# Forgejo release with source + toolchain tarballs.
|
||||
# Needs FORGEJO_TOKEN in the environment.
|
||||
|
|
@ -58,14 +60,190 @@ function cmd_version() -> int {
|
|||
return 0
|
||||
}
|
||||
|
||||
# ---- changelog rendering ----------------------------------------------------
|
||||
#
|
||||
# One changeset becomes one bullet. The body is markdown and is kept as markdown:
|
||||
# the previous shell pipeline ran it through `tr '\n' ' '`, which collapsed every
|
||||
# multi-line changeset into a single paragraph — a nested list came out as a run
|
||||
# of inline " - " fragments, and a release with a dozen changesets read as one
|
||||
# unbroken wall. Continuation lines are indented two spaces instead, so nested
|
||||
# bullets and paragraphs stay inside their bullet.
|
||||
|
||||
property Changeset { typ: pointer = "", body: pointer = "" }
|
||||
|
||||
# The conventional-commit types, in the order a reader wants them: what is new,
|
||||
# what is fixed, what got faster, then the housekeeping. A type not listed here
|
||||
# still gets a group, appended after these in first-seen order.
|
||||
function type_heading(t: pointer) -> pointer {
|
||||
if t == "feat" { return "Features" }
|
||||
if t == "fix" { return "Fixes" }
|
||||
if t == "perf" { return "Performance" }
|
||||
if t == "refactor" { return "Refactoring" }
|
||||
if t == "docs" { return "Documentation" }
|
||||
if t == "build" { return "Build" }
|
||||
if t == "ci" { return "CI" }
|
||||
if t == "test" { return "Tests" }
|
||||
if t == "style" { return "Style" }
|
||||
if t == "revert" { return "Reverts" }
|
||||
if t == "chore" { return "Chores" }
|
||||
return title_case(t)
|
||||
}
|
||||
function type_rank(t: pointer) -> int {
|
||||
if t == "feat" { return 0 }
|
||||
if t == "fix" { return 1 }
|
||||
if t == "perf" { return 2 }
|
||||
if t == "refactor" { return 3 }
|
||||
if t == "docs" { return 4 }
|
||||
if t == "build" { return 5 }
|
||||
if t == "ci" { return 6 }
|
||||
if t == "test" { return 7 }
|
||||
if t == "style" { return 8 }
|
||||
if t == "revert" { return 9 }
|
||||
if t == "chore" { return 10 }
|
||||
return 50
|
||||
}
|
||||
|
||||
# strip trailing whitespace from a line
|
||||
function rstrip(s: pointer) -> pointer {
|
||||
var n = slen(s)
|
||||
while n > 0 and is_space_all(s[n - 1]) { n -= 1 }
|
||||
return sslice(s, 0, n)
|
||||
}
|
||||
|
||||
# Parse one changeset file: the `type:`/`bump:` headers, then everything else as
|
||||
# the body. Returns a Changeset with an empty body when the file is unreadable.
|
||||
function read_changeset(path: pointer) -> Changeset {
|
||||
let cs = new Changeset
|
||||
cs.typ = ""
|
||||
cs.body = ""
|
||||
let text = read_file(path)
|
||||
if text == null { return cs }
|
||||
let b = sb_new()
|
||||
let n = slen(text)
|
||||
var i = 0
|
||||
var seen_body = false
|
||||
while i < n {
|
||||
let ln = line_at(text, i)
|
||||
i = i + slen(ln) + 1
|
||||
if not seen_body and s_starts(ln, "type:") {
|
||||
cs.typ = s_trim(sslice(ln, 5, slen(ln)))
|
||||
continue
|
||||
}
|
||||
if not seen_body and s_starts(ln, "bump:") { continue }
|
||||
# a leading blank line between the headers and the body is not body content
|
||||
if not seen_body and slen(s_trim(ln)) == 0 { continue }
|
||||
seen_body = true
|
||||
sb_puts(b, rstrip(ln))
|
||||
sb_putc(b, '\n')
|
||||
}
|
||||
# drop trailing blank lines
|
||||
var body = sb_str(b)
|
||||
var m = slen(body)
|
||||
while m > 0 and is_space_all(body[m - 1]) { m -= 1 }
|
||||
cs.body = sslice(body, 0, m)
|
||||
if cs.typ == "" { cs.typ = "chore" }
|
||||
return cs
|
||||
}
|
||||
|
||||
# Render one changeset as a markdown list item: the first line after "- ", every
|
||||
# following line indented two spaces so it stays within the item. Blank lines
|
||||
# stay blank (an indented blank line is just trailing whitespace).
|
||||
function render_bullet(b: Sb, body: pointer) -> void {
|
||||
let n = slen(body)
|
||||
var i = 0
|
||||
var first = true
|
||||
while i < n {
|
||||
let ln = line_at(body, i)
|
||||
i = i + slen(ln) + 1
|
||||
if first { sb_puts(b, "- "); first = false }
|
||||
else if slen(ln) == 0 { sb_putc(b, '\n'); continue }
|
||||
else { sb_puts(b, " ") }
|
||||
sb_puts(b, ln)
|
||||
sb_putc(b, '\n')
|
||||
}
|
||||
}
|
||||
|
||||
# the changesets in `dir`, README.md excluded, in filename order
|
||||
function load_changesets(dir: pointer) -> []Changeset {
|
||||
let out = new []Changeset
|
||||
let names = list_sorted(dir)
|
||||
var i = 0
|
||||
while i < len(names) {
|
||||
let nm = names[i]
|
||||
i += 1
|
||||
if nm == "README.md" { continue }
|
||||
if not s_ends(nm, ".md") { continue }
|
||||
let cs = read_changeset(`{dir}/{nm}`)
|
||||
if slen(cs.body) > 0 { push(out, cs) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# assemble the CHANGELOG.md section for `ver` from the pending changesets into
|
||||
# the scratch file rel_section.md (header + one bullet per changeset, sorted by type).
|
||||
# the scratch file rel_section.md: a dated header, then one "### <Heading>"
|
||||
# group per conventional-commit type, each holding its changesets as bullets.
|
||||
function render_section(ver: pointer, date: pointer, dir: pointer) -> pointer {
|
||||
let sets = load_changesets(dir)
|
||||
let b = sb_new()
|
||||
sb_puts(b, `## v{ver} — {date}\n`)
|
||||
|
||||
# walk the type groups in rank order, then any unranked type in first-seen order
|
||||
let done = new []pointer
|
||||
var emitted = 0
|
||||
while emitted < len(sets) {
|
||||
# pick the lowest-ranked type not yet emitted, ties broken by name
|
||||
var best = ""
|
||||
var bestrank = 0
|
||||
var si = 0
|
||||
while si < len(sets) {
|
||||
let t = sets[si].typ
|
||||
si += 1
|
||||
var already = false
|
||||
var di = 0
|
||||
while di < len(done) { if done[di] == t { already = true }; di += 1 }
|
||||
if already { continue }
|
||||
let r = type_rank(t)
|
||||
if best == "" or r < bestrank or (r == bestrank and str_gt(best, t)) { best = t; bestrank = r }
|
||||
}
|
||||
if best == "" { break }
|
||||
push(done, best)
|
||||
|
||||
# collect this group's bodies and sort them, so a release is reproducible
|
||||
let bodies = new []pointer
|
||||
var k = 0
|
||||
while k < len(sets) {
|
||||
if sets[k].typ == best { push(bodies, sets[k].body) }
|
||||
k += 1
|
||||
}
|
||||
strs_sort(bodies)
|
||||
|
||||
sb_puts(b, `\n### {type_heading(best)}\n\n`)
|
||||
var j = 0
|
||||
while j < len(bodies) {
|
||||
render_bullet(b, bodies[j])
|
||||
j += 1
|
||||
emitted += 1
|
||||
}
|
||||
}
|
||||
return sb_str(b)
|
||||
}
|
||||
|
||||
function build_section(ver: pointer) -> void {
|
||||
let date = capture_line("date +%Y-%m-%d")
|
||||
run(`printf '## v%s — %s\n\n' '{ver}' '{date}' > {tmp_dir()}/rel_section.md`)
|
||||
# one bullet per changeset: "- **<type>**: <body>" (body = the non-header text)
|
||||
run(`( for f in changes/*.md; do case \"$f\" in */README.md) continue;; esac; t=$(sed -n 's/^type:[[:space:]]*//p' \"$f\" | head -1); b=$(grep -vE '^(type|bump):' \"$f\" | sed '/^[[:space:]]*$/d' | tr '\\n' ' ' | sed 's/[[:space:]]*$//'); printf -- '- **%s**: %s\\n' \"$t\" \"$b\"; done | sort ) >> {tmp_dir()}/rel_section.md`)
|
||||
run(`printf '\n' >> {tmp_dir()}/rel_section.md`)
|
||||
write_file(tmp_path("rel_section.md"), render_section(ver, date, "changes"))
|
||||
}
|
||||
|
||||
# x changelog-render <version> <date> <dir> — print the CHANGELOG section that
|
||||
# `dir`'s changesets would produce. Used to re-render the sections of releases
|
||||
# cut before the renderer preserved markdown structure: check the changesets out
|
||||
# of the tag's parent commit, point this at them, and splice the result back in.
|
||||
function cmd_changelog_render() -> int {
|
||||
if arg_count() < 5 {
|
||||
err("usage: x changelog-render <version> <date> <changesets-dir>\n")
|
||||
return 1
|
||||
}
|
||||
out(render_section(arg(2), arg(3), arg(4)))
|
||||
return 0
|
||||
}
|
||||
|
||||
# prepend the scratch rel_section.md into CHANGELOG.md, above the first existing
|
||||
|
|
@ -85,36 +263,121 @@ function prepend_changelog() -> void {
|
|||
run(`cp {tmp_dir()}/rel_new.md CHANGELOG.md`)
|
||||
}
|
||||
|
||||
# ---- release artifacts + publishing -----------------------------------------
|
||||
|
||||
# Extract one release's section out of CHANGELOG.md: everything from its
|
||||
# "## vX.Y.Z" heading up to the next "## " heading. This is what a release's
|
||||
# notes are — the notes and the changelog can then never disagree.
|
||||
function changelog_section(ver: pointer) -> pointer {
|
||||
let text = read_file("CHANGELOG.md")
|
||||
if text == null { return "" }
|
||||
let head = `## v{ver} `
|
||||
let n = slen(text)
|
||||
var i = 0
|
||||
var start = -1
|
||||
while i < n {
|
||||
let ln = line_at(text, i)
|
||||
let next = i + slen(ln) + 1
|
||||
if start < 0 {
|
||||
if s_starts(ln, head) { start = i }
|
||||
} else {
|
||||
if s_starts(ln, "## ") { return sslice(text, start, i) }
|
||||
}
|
||||
i = next
|
||||
}
|
||||
if start < 0 { return "" }
|
||||
return sslice(text, start, n)
|
||||
}
|
||||
|
||||
# x changelog-section <version> — print that release's CHANGELOG section.
|
||||
function cmd_changelog_section() -> int {
|
||||
if arg_count() < 3 { err("usage: x changelog-section <version>\n"); return 1 }
|
||||
let sec = changelog_section(arg(2))
|
||||
if slen(sec) == 0 { err(`changelog-section: no section for v{arg(2)} in CHANGELOG.md\n`); return 1 }
|
||||
out(sec)
|
||||
return 0
|
||||
}
|
||||
|
||||
# the platform's SHA-256 tool (coreutils on Linux, shasum on macOS)
|
||||
function sha256_cmd() -> pointer {
|
||||
if shq("command -v sha256sum >/dev/null 2>&1") { return "sha256sum" }
|
||||
return "shasum -a 256"
|
||||
}
|
||||
|
||||
# Build the release artifacts into dist/: a reproducible source+seed tarball from
|
||||
# the tag, the toolchain built on this host, and a SHA256SUMS covering both — a
|
||||
# release without checksums asks everyone downstream to trust the transport.
|
||||
function build_artifacts(ver: pointer) -> bool {
|
||||
run("rm -rf dist && mkdir -p dist")
|
||||
if not shq(`git archive --format=tar.gz --prefix=ludic-{ver}/ -o dist/ludic-{ver}-src.tar.gz v{ver}`) {
|
||||
err(`release: git archive of v{ver} failed (is the tag present?)\n`)
|
||||
return false
|
||||
}
|
||||
if is_exec("bin/ludicc") {
|
||||
let plat = capture_line("uname -s | tr '[:upper:]' '[:lower:]'")
|
||||
let arch = capture_line("uname -m")
|
||||
if not shq(`tar -czf dist/ludic-{ver}-{plat}-{arch}.tar.gz bin selfhost/ludicc.seed.ll VERSION`) {
|
||||
err("release: building the toolchain tarball failed\n")
|
||||
return false
|
||||
}
|
||||
}
|
||||
if not shq(`cd dist && {sha256_cmd()} *.tar.gz > SHA256SUMS`) {
|
||||
err("release: writing dist/SHA256SUMS failed\n")
|
||||
return false
|
||||
}
|
||||
run("ls -l dist")
|
||||
return true
|
||||
}
|
||||
|
||||
# x publish [vX.Y.Z] — publish an already-tagged release: build the artifacts,
|
||||
# take the notes from that version's CHANGELOG section, and create the Forgejo
|
||||
# release. Defaults to the version in VERSION. This is what CI runs on a tag
|
||||
# push, and what `x release --publish` calls once it has tagged.
|
||||
function publish_tag(ver: pointer) -> int {
|
||||
if getenv_or("FORGEJO_TOKEN", "") == "" { err("publish: set FORGEJO_TOKEN (a Forgejo access token)\n"); return 1 }
|
||||
let body = tmp_path("rel_notes.md")
|
||||
let sec = changelog_section(ver)
|
||||
if slen(sec) == 0 { err(`publish: no CHANGELOG section for v{ver}\n`); return 1 }
|
||||
write_file(body, sec)
|
||||
if not build_artifacts(ver) { return 1 }
|
||||
if forgejo_publish(`v{ver}`, body, "dist") != 0 {
|
||||
err("publish: creating the Forgejo release failed\n")
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function cmd_publish() -> int {
|
||||
var ver = read_version_or("")
|
||||
if arg_count() >= 3 {
|
||||
var a = arg(2)
|
||||
if s_starts(a, "v") { a = sslice(a, 1, slen(a)) }
|
||||
ver = a
|
||||
}
|
||||
if ver == "" { err("publish: no version given and no VERSION file\n"); return 1 }
|
||||
return publish_tag(ver)
|
||||
}
|
||||
|
||||
# push main + the tag and create the Forgejo release with build artifacts.
|
||||
function publish_release(ver: pointer) -> int {
|
||||
if getenv_or("FORGEJO_TOKEN", "") == "" { err("release --publish: set FORGEJO_TOKEN (a Forgejo access token)\n"); return 1 }
|
||||
if not shq("git push origin HEAD") { err("release: git push (main) failed\n"); return 1 }
|
||||
if not shq(`git push origin v{ver}`) { err("release: git push (tag) failed\n"); return 1 }
|
||||
# artifacts: a reproducible source+seed tarball, and the built macOS toolchain.
|
||||
run("mkdir -p dist")
|
||||
run(`git archive --format=tar.gz --prefix=ludic-{ver}/ -o dist/ludic-{ver}-src.tar.gz v{ver}`)
|
||||
if is_exec("bin/ludicc") {
|
||||
let plat = capture_line("uname -s | tr '[:upper:]' '[:lower:]'")
|
||||
let arch = capture_line("uname -m")
|
||||
run(`tar -czf dist/ludic-{ver}-{plat}-{arch}.tar.gz bin selfhost/ludicc.seed.ll VERSION`)
|
||||
}
|
||||
# the changelog section written by build_section is the release body
|
||||
if forgejo_publish(`v{ver}`, tmp_path("rel_section.md"), "dist") != 0 {
|
||||
err("release: creating the Forgejo release failed\n"); return 1
|
||||
}
|
||||
return 0
|
||||
return publish_tag(ver)
|
||||
}
|
||||
|
||||
function cmd_release() -> int {
|
||||
# parse args: an optional level positional, and a --publish flag
|
||||
var level = ""
|
||||
var publish = false
|
||||
var dry = false
|
||||
var ai = 2
|
||||
while ai < arg_count() {
|
||||
let a = arg(ai)
|
||||
if (a == "--publish") { publish = true }
|
||||
else { if (a == "--dry-run") { dry = true }
|
||||
else { if (a == "major") or (a == "minor") or (a == "patch") { level = a }
|
||||
else { err(`release: unknown argument {a}\n`); return 1 } }
|
||||
else { err(`release: unknown argument {a}\n`); return 1 } } }
|
||||
ai += 1
|
||||
}
|
||||
|
||||
|
|
@ -129,6 +392,17 @@ function cmd_release() -> int {
|
|||
print(`releasing v{ver} ({level} bump from {cur})`)
|
||||
|
||||
build_section(ver)
|
||||
# --dry-run stops here: the section is rendered to stdout and nothing on disk,
|
||||
# in git, or on the remote is touched. This is the cheap way to read a release
|
||||
# before cutting it, which the old all-or-nothing command had no answer for.
|
||||
if dry {
|
||||
print("")
|
||||
let sec = read_file(tmp_path("rel_section.md"))
|
||||
if sec != null { out(sec) }
|
||||
print("")
|
||||
print(` dry run — nothing written. cut it with: x release {level}`)
|
||||
return 0
|
||||
}
|
||||
prepend_changelog()
|
||||
if not write_file("VERSION", `{ver}\n`) { err("release: cannot write VERSION\n"); return 1 }
|
||||
run("rm -f $(ls changes/*.md | grep -v '/README.md')")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue