diff --git a/build.zig.zon b/build.zig.zon index 5132dc046..1c11debc0 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -27,6 +27,11 @@ .fizzy_sdk = .{ .path = "sdk/", }, + // A plugin that imports `tape` and `replay`, linked into the test build as an app would. + .tape_fixture = .{ + .path = "tests/fixtures/tape-plugin", + .lazy = true, + }, // Fizzy's native backend (SDL3, its GPU renderer, the platform pieces), with SDL, // zig-objc and zigwin32 pinned there. App-only: plugins never link it. .fizzy_backend = .{ diff --git a/build/app.zig b/build/app.zig index ad5f442ff..49e4e8e45 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1513,6 +1513,39 @@ fn addTests( const fixture_test = b.addTest(.{ .name = "rehome-fixture", .root_module = module }); test_step.dependOn(&b.addRunArtifact(fixture_test).step); } + // What a plugin does with `tape` and `replay` (`tests/fixtures/tape-plugin/play.zig`), through + // the path an application's plugin takes: its own `staticModule`, then `rehome` onto this + // executable's framework. The fixture's own build tests the same file as a dylib. + // Fizzy's own build only: the fixture is not in `.paths`, so an app fetching fizzy lacks it. + if (cfg.app_is_fizzy) if (b.lazyDependency("tape_fixture", .{})) |fixture_dep| { + const source = b.lazyImport(@This(), "tape_fixture").?.staticModule(fixture_dep.builder, .{ + .target = target, + .optimize = optimize, + .dvui = dvui_testing_dep.module("dvui_testing"), + .core = core_module_test, + .sdk = sdk_module_test, + .icons = icons_test, + }); + const consumer = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }); + const module = @import("plugins.zig").rehome(b, .{ + .target = target, + .optimize = optimize, + .dvui = dvui_testing_dep.module("dvui_testing"), + .core = core_module_test, + .sdk = sdk_module_test, + .icons = icons_test, + .backend = null, + .workbench_opts = workbench_opts, + .consumer = consumer, + }, "tape_fixture", source); + const play_tests = b.addTest(.{ + .name = "fizzy-tape-plugin-bundled-tests", + .root_module = module, + .filters = test_filters, + }); + test_integration_step.dependOn(&b.addRunArtifact(play_tests).step); + check_integration_step.dependOn(&play_tests.step); + }; const workbench_module_test = workbench_plugin.addStaticModule(b, target, optimize, .{ .dvui = dvui_testing_dep.module("dvui_testing"), .core = core_module_test, diff --git a/build/sdk.zig b/build/sdk.zig index e40bdee49..ca4ce5d39 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -59,6 +59,9 @@ pub fn wireSdkModule( sdk_module.addImport("dvui", dvui_module); sdk_module.addImport("proxy_bridge", proxy_bridge_module); sdk_module.addImport("core", core_module); + // Their data types are on the plugin boundary (`sdk/src/dylib.zig`). + sdk_module.addImport("tape", core_module.import_table.get("tape").?); + sdk_module.addImport("replay", core_module.import_table.get("replay").?); // `sdk_version` as a *named* module rather than a relative `@import`: `sdk/sdk_version.zig` // is the single edit site for the triplet, but it sits outside this module's root // (`sdk/src/`), and Zig confines a module's relative imports to its own root — a named diff --git a/scripts/check-examples.sh b/scripts/check-examples.sh index f866248e8..1b580daba 100755 --- a/scripts/check-examples.sh +++ b/scripts/check-examples.sh @@ -127,6 +127,11 @@ if [[ -n $builds ]]; then printf '%s\n' $builds | xargs -P "${EXAMPLES_JOBS:-3}" -n 1 bash -c 'build_one "$1"' _ fi +# A plugin that imports `tape` and `replay` builds and plays a tape as a dylib too +# (tests/fixtures/tape-plugin; the same file is tested linked into the app by `test-integration`). +echo "== tape-plugin fixture, as a dylib" +(cd "$root/tests/fixtures/tape-plugin" && zig build test --cache-dir "$work/tape-plugin-cache" --prefix "$work/tape-plugin-out") + # An app's list is exact: one naming none of fizzy's, workbench included, fails while build.zig # configures, which compiles nothing. The build must fail and say which plugin is missing. echo "== app-only fixture, expected to fail naming fizzy.plugins.workbench" diff --git a/sdk/AGENTS.md b/sdk/AGENTS.md index e0ad42b7a..e0b41e0ee 100644 --- a/sdk/AGENTS.md +++ b/sdk/AGENTS.md @@ -9,6 +9,10 @@ file is the rules for changing it. `sdk_version.zig` outside a release PR (`CONTRIBUTING.md`, "Changing the SDK"). - **A new boundary type goes in `sdk_boundary_types`** (`src/dylib.zig`), or the fingerprint never sees it change. +- **`tape` and `replay` are boundary too.** `tape.binary.version` is folded into the shape + fingerprint and the tape data types are in `tape_boundary_types` (`src/dylib.zig`): bump the version or + change one, and the fingerprint moves. A store plugin imports both, as a bundled one does + (`plugin.create`; `tests/fixtures/tape-plugin`). - **This directory ships on its own** as `fizzy-sdk-v*.tar.gz` (`scripts/pack-sdk.sh`), holding only what `build.zig.zon`'s `.paths` lists. A new top-level file here goes in that list too; `scripts/check-examples.sh` builds the example plugin from the packed tarball and catches it. diff --git a/sdk/plugin_sdk.zig b/sdk/plugin_sdk.zig index 287cdc616..787a0f6d1 100644 --- a/sdk/plugin_sdk.zig +++ b/sdk/plugin_sdk.zig @@ -43,6 +43,9 @@ pub const Modules = struct { sdk: *std.Build.Module, dvui: *std.Build.Module, proxy_bridge: *std.Build.Module, + /// `core`'s own `tape` and `replay`, the instances a statically linked plugin imports. + tape: *std.Build.Module, + replay: *std.Build.Module, }; pub const ModulesOptions = struct { @@ -164,6 +167,8 @@ fn modulesFromDep(fizzy_dep: *std.Build.Dependency) Modules { .sdk = fizzy_dep.module("fizzy_sdk"), .dvui = fizzy_dep.module("dvui"), .proxy_bridge = fizzy_dep.module("proxy_bridge"), + .tape = fizzy_dep.module("tape"), + .replay = fizzy_dep.module("replay"), }; } @@ -176,6 +181,8 @@ pub fn addImports(mod: *std.Build.Module, plugin_modules: Modules) void { mod.addImport("fizzy_sdk", plugin_modules.sdk); mod.addImport("dvui", plugin_modules.dvui); mod.addImport("proxy_bridge", plugin_modules.proxy_bridge); + mod.addImport("tape", plugin_modules.tape); + mod.addImport("replay", plugin_modules.replay); } fn module( @@ -662,6 +669,9 @@ pub fn exportModules( sdk_mod.addImport("dvui", dvui_proxy_mod); sdk_mod.addImport("proxy_bridge", proxy_bridge_mod); sdk_mod.addImport("core", core_mod); + // The fingerprint hashes `tape`'s and `replay`'s data types (`dylib.zig`). + sdk_mod.addImport("tape", core_mod.import_table.get("tape").?); + sdk_mod.addImport("replay", core_mod.import_table.get("replay").?); // See the matching comment in `build/sdk.zig`. `sdk_version.zig` needs no `repoPath` hop: // unlike `src/…`, it lives at this package's root in *both* layouts (in-repo `fizzy/sdk/` // and the release tarball, whose root is that same directory). diff --git a/sdk/src/dylib.zig b/sdk/src/dylib.zig index 2ac1adc19..216d5259d 100644 --- a/sdk/src/dylib.zig +++ b/sdk/src/dylib.zig @@ -13,6 +13,8 @@ const builtin = @import("builtin"); const dvui = @import("dvui"); const proxy_bridge = @import("proxy_bridge"); const core = @import("core"); +const tape = @import("tape"); +const replay = @import("replay"); const fingerprint = @import("fingerprint.zig"); const dvui_context = @import("dvui_context.zig"); const runtime = @import("runtime.zig"); @@ -189,6 +191,36 @@ const sdk_boundary_types = .{ VersionTriplet, }; +/// A plugin's demo crosses the boundary as bytes (`Demo.tape`), so these are shared by what they +/// spell, not by pointer: `tape.binary.version` is folded in `sdk_shape_fingerprint`, and the +/// data types a tape is made of are hashed here. `Stage` is the seam a plugin's app fills in. +const tape_boundary_types = .{ + tape.Tape, + tape.Tape.Op, + tape.Tape.Action, + tape.Tape.Button, + tape.Tape.Command, + tape.Tape.Step, + tape.Tape.Scroll, + tape.Tape.Target, + tape.Tape.Wait, + tape.Tape.Skip, + tape.Tape.Until, + tape.Tape.Caption, + tape.Tape.Chapter, + tape.Tape.Keyframe, + tape.Tape.Setting, + tape.Tape.File, + tape.Tape.Check, + tape.Sequencer.Point, + tape.Sequencer.Failure, + tape.Sequencer.Sink, + tape.Sequencer.Sink.VTable, + replay.Player, + replay.Stage, + replay.Stage.VTable, +}; + const entry_symbol_types = .{ RegisterFn, SetGlobalsFn, @@ -280,6 +312,8 @@ pub const sdk_shape_fingerprint: u64 = blk: { h = fingerprint.hashAllShape(h, sdk_boundary_types, 6); h = fingerprint.hashAllShape(h, entry_symbol_types, 3); h = fingerprint.hashAllShape(h, dvui_shared_state_types, 6); + h = fingerprint.hashAllShape(h, tape_boundary_types, 6); + h = fingerprint.foldString(h, std.mem.asBytes(&tape.binary.version)); break :blk h; }; diff --git a/sdk/src/version.zig b/sdk/src/version.zig index dd33bb112..565c00fc6 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x68bbdb31985c4555; +pub const recorded_sdk_shape_fingerprint: u64 = 0x9bf131098b2d283c; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/tests/fixtures/tape-plugin/build.zig b/tests/fixtures/tape-plugin/build.zig new file mode 100644 index 000000000..67a6ed6e0 --- /dev/null +++ b/tests/fixtures/tape-plugin/build.zig @@ -0,0 +1,22 @@ +const std = @import("std"); +const fizzy = @import("fizzy"); + +/// The plugin as an application links it (`build/app.zig` builds it this way and plays its tests). +// anytype: the app's framework struct, whose type `fizzy.plugin.staticModule` owns. +pub fn staticModule(b: *std.Build, framework: anytype) *std.Build.Module { + return fizzy.plugin.staticModule(b, framework, .{}); +} + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const plugin = fizzy.plugin.create(b, .{ .target = target, .optimize = optimize }); + + // The plugin's module as the dylib sees it, tested: `tape` and `replay` resolve in a store + // plugin as they do in a bundled one (`build/app.zig` tests `play.zig` the other way). + const tests = b.addTest(.{ .name = "tape-fixture-tests", .root_module = plugin.module }); + const test_step = b.step("test", "Run the fixture's tests as a dylib plugin sees its imports"); + test_step.dependOn(&b.addRunArtifact(tests).step); + test_step.dependOn(&plugin.lib.step); +} diff --git a/tests/fixtures/tape-plugin/build.zig.zon b/tests/fixtures/tape-plugin/build.zig.zon new file mode 100644 index 000000000..759cf5868 --- /dev/null +++ b/tests/fixtures/tape-plugin/build.zig.zon @@ -0,0 +1,18 @@ +.{ + .name = .tape_fixture, + .version = "0.1.0", + .minimum_zig_version = "0.17.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "plugin.zig", + "plugin.zig.zon", + "play.zig", + }, + .fingerprint = 0x39f10a95729a0ebe, + .dependencies = .{ + .fizzy = .{ + .path = "../../../sdk", + }, + }, +} diff --git a/tests/fixtures/tape-plugin/play.zig b/tests/fixtures/tape-plugin/play.zig new file mode 100644 index 000000000..ca6d89696 --- /dev/null +++ b/tests/fixtures/tape-plugin/play.zig @@ -0,0 +1,74 @@ +//! A plugin's use of `tape` and `replay`: write a demo with `Script`, ship it as bytes, load it and +//! play it. The same file is tested twice: linked into an app (`build/app.zig`, the imports +//! `plugins.staticModule` gives) and built as a plugin dylib (this directory's `build.zig`, the +//! imports `plugin.create` gives), which is how a plugin's one source builds both ways. +const std = @import("std"); +const tape = @import("tape"); +const replay = @import("replay"); + +const Played = struct { + commands: usize = 0, + + fn sink(self: *Played) tape.Sequencer.Sink { + return .{ .ctx = self, .vtable = &.{ + .locate = locate, + .moveTo = moveTo, + .button = button, + .scroll = scroll, + .key = key, + .text = text, + .command = command, + .keyframe = keyframe, + .holds = holds, + .failed = failed, + } }; + } + fn locate(_: *anyopaque, _: tape.Tape.Target) ?tape.Sequencer.Point { + return null; + } + fn moveTo(_: *anyopaque, _: tape.Sequencer.Point) void {} + fn button(_: *anyopaque, _: tape.Tape.Button, _: bool) void {} + fn scroll(_: *anyopaque, _: tape.Tape.Scroll) void {} + fn key(_: *anyopaque, _: []const u8) void {} + fn text(_: *anyopaque, _: []const u8) void {} + fn command(ctx: *anyopaque, cmd: tape.Tape.Command) void { + const self: *Played = @ptrCast(@alignCast(ctx)); + if (std.mem.eql(u8, cmd.id, "fixture.hello")) self.commands += 1; + } + fn keyframe(_: *anyopaque, _: *const tape.Tape.Keyframe) void {} + fn holds(_: *anyopaque, _: tape.Tape.Until) bool { + return true; + } + fn failed(_: *anyopaque, _: tape.Sequencer.Failure) void {} +}; + +test "a plugin writes a demo, ships it as bytes, and plays it back" { + const gpa = std.testing.allocator; + var owned = owned: { + // `finish` hands the arena to `owned`, so `script` owns it only until then. + var script: tape.Script = .init(gpa, "fixture", "Fixture"); + errdefer script.deinit(); + try script.keyframe(.{ .root = "demo://fixture" }); + try script.command("fixture.hello"); + break :owned try script.finish(); + }; + defer owned.deinit(); + + const bytes = try tape.binary.encode(gpa, owned.tape, .{}); + defer gpa.free(bytes); + var loaded = try tape.Tape.load(gpa, bytes, .{}); + defer loaded.deinit(); + + var played: Played = .{}; + var seq: tape.Sequencer = .init(&loaded.tape); + var frames: usize = 0; + while (!seq.done() and frames < 1_000) : (frames += 1) { + _ = seq.advance(seq.now + 16, 16, played.sink()); + } + try std.testing.expectEqual(@as(usize, 1), played.commands); +} + +test "replay is the module that drives a window with a tape" { + // The player takes the tape `tape` made: one `Tape` type on both sides of the import. + try std.testing.expect(@hasDecl(replay.Player, "play")); +} diff --git a/tests/fixtures/tape-plugin/plugin.zig b/tests/fixtures/tape-plugin/plugin.zig new file mode 100644 index 000000000..4f3389782 --- /dev/null +++ b/tests/fixtures/tape-plugin/plugin.zig @@ -0,0 +1,22 @@ +//! Fixture: a plugin that imports `tape` and `replay`, built as a dylib to show a store plugin gets +//! both (`play.zig` holds what it does with them). Registers nothing but itself. +const sdk = @import("fizzy_sdk"); + +pub const plugin_options = @import("fizzy_plugin_options"); + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = plugin_options.id, + .display_name = plugin_options.name, +}; + +const vtable: sdk.Plugin.VTable = .{}; + +pub fn register(host: *sdk.Host) !void { + try host.registerPlugin(&plugin); +} + +test { + _ = @import("play.zig"); +} diff --git a/tests/fixtures/tape-plugin/plugin.zig.zon b/tests/fixtures/tape-plugin/plugin.zig.zon new file mode 100644 index 000000000..f0f6d5e85 --- /dev/null +++ b/tests/fixtures/tape-plugin/plugin.zig.zon @@ -0,0 +1,8 @@ +.{ + .id = "tape_fixture", + .name = "Tape fixture", + .version = "0.1.0", + .min_sdk_version = "", + .description = "Imports tape and replay, as a dylib.", + .tags = .{"fixture"}, +}