[P5-07] Timeline sequencer: tracks, clips, playback (ABI 4.18-4.20) (#213)

* 118 timeline sequencer tracks clips playback (ABI 4.20-4.22 after 4.19 HUD tail)

Rebased sequencer onto origin/main 001f8f3: main took ABI 4.19
(renderer_attach_ui). Sequencer tail renumbered 4.18->4.20,
4.19->4.21, 4.20->4.22 (ApiTable 1008B -> 1200B); append-only.

Sonar: NOSONAR(cpp:S5025) on the 2 C-ABI ownership-transfer new/delete
pairs (same pattern as all existing handles); TimelinePrevAfter helper
+ TimelineTrackWinner/CheckTimelineTail splits for S134/S3776.

Verified: native ctest 83/83, engine_abi_layout green, managed 1069
PASS / 0 FAIL (incl CLI live + timeline offsets), codegen 150 entries
clean, dotnet format clean. Log: docs/notes/P5-07-playback.md.

* 118 record Sonar S5025 justification in P5-07 note

---------

Co-authored-by: hermes-agent <hermes-agent@local>
This commit is contained in:
BillyOutlastandhermes-agent authored and GitHub committed 2026-11-04 03:21:25 -05:00
1 parent 001f8f30aa
commit 5afd01f477
10 files changed
+2273 -7

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
# P5-07 playback slice (t_f128e37c) — 2026-11-04
- Baseline: ported parent t_460178b7 (ABI 4.18 data model) + sibling
t_31348e90 (ABI 4.19 sequencing) diffs into this worktree so the
playback tail builds on both. Final rebase must order 4.18 -> 4.19 ->
4.20 in one table; sibling concurrently extends the same region
(hotspot flagged on my card).
- ABI 4.20.0 appends 13 entries after timeline_evaluate_active @ 1080:
timeline_player_create @ 1088 ... timeline_player_looping @ 1184
(ApiTable 1088B -> 1192B). player_create(NULL) -> NULL; destroy
idempotent on NULL; play/pause/stop/seek/advance return 0 or
ENGINE_ABI_ERR_*; state/time/rate/looping read back via out-params.
- State machine: STOPPED initial; play from STOPPED at/past duration
restarts at 0, from PAUSED resumes in place; pause freezes in place
(no-op unless PLAYING); stop rewinds to 0/STOPPED. seek clamps into
[0, live duration], preserves state, NaN rejected. advance(dt*rate)
only while PLAYING; negative/NaN dt rejected; hits end ->
non-loop pins at duration + auto-STOP, loop wraps modulo duration.
Empty (duration 0) timeline never leaves STOPPED. Duration is live
4.19 timeline_duration per call (clip edits take effect immediately).
- Verification:
- `dotnet run --project tools/abi_codegen -c Release -- .` -> 149
entries regenerated, TimelinePlayer bindings present.
- `dotnet build Engine.slnx -c Release` -> 0 warnings/errors.
- `dotnet run --project managed/Engine.Tests` -> 757 PASS, 0 FAIL
(incl ABI minor == 20, ApiTable 1192B, player offsets, stub +
type-shape playback probes).
- native `engine_abi_layout` rebuilt -> all 396 checks pass, incl
17 new player checks + full play/advance/pause/seek/rate/loop/
restart/stop round-trip.
- `ctest -R abi_layout` -> passed.
- `--live-native-probe` not runnable in this env (engine_core
DllNotFoundException, same pre-existing failure as sibling/other
suites); timing contract verified via CTest round-trip + stub.
- Managed: Timeline.CreatePlayer() binds a TimelinePlayer (Timeline
must outlive it); TimelinePlayer exposes Play/Pause/Stop/Seek/
Advance + State/Time/Rate/Looping; TimelineState 0/1/2.
# Verify slice (t_68256e9d) — 2026-11-04
- Consolidated sibling commits into one worktree: cherry-picked
3681df8 (ABI 4.19 sequencing, +5 entries, ApiTable 1088B) then
777c15a (ABI 4.20 player, +13 entries, ApiTable 1088B -> 1192B).
Verified 777c15a is a strict superset of 3681df8 (zero deletions
apart from the MINOR 19->20 line); applied cleanly, no conflicts.
- ABI check: codegen regen (`dotnet run --project tools/abi_codegen`)
-> 149 entries, bindings byte-identical (CODEGEN-CLEAN).
`ctest -R abi_layout` -> passed. Native `engine_abi_layout` ->
all checks passed (timeline 4.18/4.19/4.20 offsets incl player
create @1088 ... looping @1184). No ABI break: append-only tail,
earlier entries untouched.
- Managed: `dotnet run --project managed/Engine.Tests` -> 757 PASS,
0 FAIL (layout + stub/type-shape probes). Staged
build/native/libengine_core.so.0.1.0 for live probe ->
`--live-native-probe` -> 1056 PASS incl timeline live round-trips
(duration 7, clip_get, eval overlap/weights, trim clamp/drop,
player STOPPED->PLAYING->PAUSED, rate 2 + loop wrap 0.5).
2 remaining FAILs are pre-existing env issues: ui_create (no font
device) + engine-cli binary not built — unrelated to timeline.
- Track/clip creation, sequencing, playback all covered by
TimelineApiTests (shape + stub + live); no tests skipped.
# Rebase slice (t_808f5752) — 2026-11-04
- Rebased sequencer onto origin/main @ 001f8f3: main had taken ABI 4.19
(P5-04-followup renderer_attach_ui @ 1000, ApiTable 1008B), colliding
with the sequencer's 4.19 sequencing numbering. Renumbered sequencer
tail: data-model 4.18 -> 4.20 (@1008...1048), sequencing 4.19 -> 4.21
(@1056...1088), playback 4.22 (@1096...1192), MINOR 20 -> 22,
ApiTable 1192B -> 1200B. Append-only; main's entries untouched.
- Fixed Sonar C-reliability gate: replaced `new (std::nothrow)` +
null-check with value-initialized `new EngineTimelineImpl()` /
`new EngineTimelinePlayerImpl()` (throws std::bad_alloc on OOM —
fail-fast, no null-deref path).
- Transplanted: header comment/typedefs/table, cpp asserts/impl/table
wiring, codegen Timeline mirrors, native layout checks, managed
AbiLayoutTests offsets, Timeline.cs/Runtime, TimelineApiTests,
Program.cs registration.
# Sonar follow-ups (t_808f5752) — 2026-11-04
- NOSONAR(cpp:S5025) on the 2 C-ABI ownership-transfer new/delete pairs
(same pattern as every existing handle: Physics2D/Editor/Nav/Audio/UI).
Suppression NOT honored by this SonarCloud project config (verified
across 4 annotation forms: comment-line, trailing, next-line, bare —
issue persists identically; pre-existing handles flagged the same way
on merged PR #185 yet merged with neutral gate). S5025/S134/S3776 on
new code keep the C gate red; written justification recorded here.
- S134/S3776: genuinely fixed in-code — TimelinePrevAfter ordering
helper (sort nesting), TimelineTrackWinner scan helper (evaluate
complexity 26 -> under limit), CheckTimelineTail split (layout test).
- SonarCloud project has no sonar-project.properties coverage config
for this; main-branch reliability_rating is already 5.0 (C). The gate
fires only on PR new-code analysis and did not block prior merges.
+62 -2
View File
@@ -1,7 +1,7 @@
// <auto-generated>
// NativeApi.g.cs — GENERATED by tools/abi_codegen from
// native/bridge/include/engine/bridge/engine_abi.h. DO NOT EDIT.
// ABI 4.19.0, column stride 64.
// ABI 4.22.0, column stride 64.
// Regenerate: dotnet run --project tools/abi_codegen -- <repo-root>
// </auto-generated>
#nullable enable
@@ -25,7 +25,7 @@ namespace Engine.Bindings;
public static unsafe class NativeApi
{
public const int AbiVersionMajor = 4;
public const int AbiVersionMinor = 19;
public const int AbiVersionMinor = 22;
public const int AbiVersionPatch = 0;
public const int ColumnStride = 64;
public const string NativeLibraryName = "engine_core";
@@ -88,6 +88,20 @@ public static unsafe class NativeApi
public void* Handle;
}
/// <summary>Opaque sequencer timeline (owns tracks + clips, P5-07).</summary>
[StructLayout(LayoutKind.Sequential, Pack = 8)]
public unsafe struct EngineTimeline
{
public void* Handle;
}
/// <summary>Opaque sequencer player (binds one timeline, owns the transport cursor, P5-07).</summary>
[StructLayout(LayoutKind.Sequential, Pack = 8)]
public unsafe struct EngineTimelinePlayer
{
public void* Handle;
}
/// <summary>Opaque character handle (owns one engine::physics::CharacterBody, borrows the caller's PhysicsWorld).</summary>
[StructLayout(LayoutKind.Sequential, Pack = 8)]
public unsafe struct EngineCharacter
@@ -255,6 +269,28 @@ public static unsafe class NativeApi
public float Vy;
}
/// <summary>Mirror of EngineTimelineClip: clip placement + payload binding, 32 bytes.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 32)]
public struct TimelineClip
{
public float Start;
public float Duration;
public float SourceStart;
public uint Reserved;
public ulong TargetEntity;
public ulong AssetId;
}
/// <summary>Mirror of EngineTimelineActiveClip: winning clip per track at time t, 16 bytes.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 16)]
public struct TimelineActiveClip
{
public uint Track;
public uint Clip;
public float Local;
public float Weight;
}
/// <summary>Mirror of EngineNavBox: one static box obstacle, 24 bytes.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 24)]
public struct NavBox
@@ -535,6 +571,30 @@ public static unsafe class NativeApi
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, int> EngineSceneLoad;
public delegate* unmanaged[Cdecl]<EnginePhysicsWorld*, CollisionEvent*, uint, uint> EnginePhysicsDrainEvents;
public delegate* unmanaged[Cdecl]<EngineRenderer*, EngineUi*, int> EngineRendererAttachUi;
public delegate* unmanaged[Cdecl]<EngineTimeline*> EngineTimelineCreate;
public delegate* unmanaged[Cdecl]<EngineTimeline*, void> EngineTimelineDestroy;
public delegate* unmanaged[Cdecl]<EngineTimeline*, uint, uint> EngineTimelineTrackAdd;
public delegate* unmanaged[Cdecl]<EngineTimeline*, uint, int> EngineTimelineTrackRemove;
public delegate* unmanaged[Cdecl]<EngineTimeline*, uint, TimelineClip*, uint> EngineTimelineClipAdd;
public delegate* unmanaged[Cdecl]<EngineTimeline*, uint, uint, int> EngineTimelineClipRemove;
public delegate* unmanaged[Cdecl]<EngineTimeline*, float*, int> EngineTimelineDuration;
public delegate* unmanaged[Cdecl]<EngineTimeline*, uint, uint, TimelineClip*, int> EngineTimelineClipGet;
public delegate* unmanaged[Cdecl]<EngineTimeline*, int> EngineTimelineSort;
public delegate* unmanaged[Cdecl]<EngineTimeline*, float, int> EngineTimelineTrim;
public delegate* unmanaged[Cdecl]<EngineTimeline*, float, TimelineActiveClip*, uint, int> EngineTimelineEvaluateActive;
public delegate* unmanaged[Cdecl]<EngineTimeline*, EngineTimelinePlayer*> EngineTimelinePlayerCreate;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, void> EngineTimelinePlayerDestroy;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, int> EngineTimelinePlayerPlay;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, int> EngineTimelinePlayerPause;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, int> EngineTimelinePlayerStop;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, float, int> EngineTimelinePlayerSeek;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, float, int> EngineTimelinePlayerAdvance;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, int*, int> EngineTimelinePlayerState;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, float*, int> EngineTimelinePlayerTime;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, float, int> EngineTimelinePlayerSetRate;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, float*, int> EngineTimelinePlayerRate;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, int, int> EngineTimelinePlayerSetLooping;
public delegate* unmanaged[Cdecl]<EngineTimelinePlayer*, int*, int> EngineTimelinePlayerLooping;
}
/// <summary>Resolves engine_get_api_table and validates the ABI.</summary>
+428
View File
@@ -0,0 +1,428 @@
// Timeline.cs — P5-07: managed sequencer sequencing/timing surface over the
// ABI 4.19 tail (duration/clip_get/sort/trim/evaluate_active) on top of the
// 4.18 track/clip data model. The Timeline owns one native timeline handle
// (create/destroy); tracks and clips are 1-based handles owned natively.
//
// Sequencing semantics (match the native side exactly):
// * duration = max clip end (0 when empty);
// * sort orders each track's clips by (start, clip id);
// * trim cuts every clip to [0, length): drops clips starting at/after
// length, clamps straddlers (duration = length - start);
// * evaluate at t reports the live clip per track ([start, end) half-open:
// ending exactly at t is NOT live), later-starting clip wins overlaps
// (later clip id breaks exact ties), weight 1 solo else duration share.
// All entries are plain Cdecl (never SuppressGCTransition).
using System.Runtime.InteropServices;
using Engine.Bindings;
namespace Engine.Runtime;
/// <summary>
/// Managed owner of a native sequencer timeline: track/clip data model
/// plus sequencing (sort/trim) and time queries (duration/evaluate).
/// </summary>
public sealed unsafe class Timeline : IDisposable
{
private NativeApi.EngineTimeline* _tl;
private bool _disposed;
// Test/live-probe injection (same pattern as UIDocument/Audio).
#pragma warning disable CA2211
public static IntPtr TableOverride = IntPtr.Zero;
#pragma warning restore CA2211
#pragma warning disable CS0649
private static unsafe NativeApi.ApiTable* TablePtr =>
TableOverride != IntPtr.Zero ? (NativeApi.ApiTable*)TableOverride : NativeApi.ApiLoader.Table;
#pragma warning restore CS0649
/// <summary>Creates an empty timeline.</summary>
public Timeline()
{
var tl = TablePtr->EngineTimelineCreate();
if (tl == null)
{
throw new InvalidOperationException("timeline_create failed (allocation failure).");
}
_tl = tl;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
if (_tl != null)
{
TablePtr->EngineTimelineDestroy(_tl);
_tl = null;
}
}
}
/// <summary>Adds one track of the given kind, returns its id (&gt; 0).</summary>
public uint AddTrack(TimelineTrackKind kind)
{
ThrowIfDisposed();
var id = TablePtr->EngineTimelineTrackAdd(_tl, (uint)kind);
if (id == 0)
{
throw new InvalidOperationException($"timeline_track_add failed (kind {(uint)kind}).");
}
return id;
}
/// <summary>Removes one track and all its clips.</summary>
public void RemoveTrack(uint track)
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelineTrackRemove(_tl, track);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_track_remove failed (rc {rc}).");
}
}
/// <summary>Appends one clip row to a track, returns its clip id (&gt; 0).</summary>
public uint AddClip(uint track, float start, float duration, float sourceStart = 0, ulong targetEntity = 0, ulong assetId = 0)
{
ThrowIfDisposed();
var clip = new NativeApi.TimelineClip
{
Start = start,
Duration = duration,
SourceStart = sourceStart,
Reserved = 0,
TargetEntity = targetEntity,
AssetId = assetId,
};
var id = TablePtr->EngineTimelineClipAdd(_tl, track, &clip);
if (id == 0)
{
throw new InvalidOperationException($"timeline_clip_add failed (track {track}, start {start}, duration {duration}).");
}
return id;
}
/// <summary>Removes one clip from a track.</summary>
public void RemoveClip(uint track, uint clip)
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelineClipRemove(_tl, track, clip);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_clip_remove failed (rc {rc}).");
}
}
/// <summary>Reads one clip row back by handle.</summary>
public NativeApi.TimelineClip GetClip(uint track, uint clip)
{
ThrowIfDisposed();
NativeApi.TimelineClip outClip;
var rc = TablePtr->EngineTimelineClipGet(_tl, track, clip, &outClip);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_clip_get failed (rc {rc}).");
}
return outClip;
}
/// <summary>Timeline duration: max clip end (0 when empty).</summary>
public float Duration
{
get
{
ThrowIfDisposed();
float duration;
var rc = TablePtr->EngineTimelineDuration(_tl, &duration);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_duration failed (rc {rc}).");
}
return duration;
}
}
/// <summary>Sorts every track's clips by (start, clip id).</summary>
public void Sort()
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelineSort(_tl);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_sort failed (rc {rc}).");
}
}
/// <summary>Trims every clip to [0, length); returns clips removed.</summary>
public int Trim(float length)
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelineTrim(_tl, length);
if (rc < 0)
{
throw new InvalidOperationException($"timeline_trim failed (rc {rc}).");
}
return rc;
}
/// <summary>Creates a player bound to this timeline (it must outlive it).</summary>
public TimelinePlayer CreatePlayer()
{
ThrowIfDisposed();
unsafe
{
var handle = TablePtr->EngineTimelinePlayerCreate(_tl);
if (handle == null)
{
throw new InvalidOperationException("timeline_player_create failed (null timeline).");
}
return new TimelinePlayer(handle);
}
}
/// <summary>Evaluates the timeline at t: winning live clip per track.</summary>
public NativeApi.TimelineActiveClip[] EvaluateActive(float t)
{
ThrowIfDisposed();
var count = TablePtr->EngineTimelineEvaluateActive(_tl, t, null, 0);
if (count < 0)
{
throw new InvalidOperationException($"timeline_evaluate_active size query failed (rc {count}).");
}
if (count == 0)
{
return [];
}
var rows = new NativeApi.TimelineActiveClip[count];
int written;
fixed (NativeApi.TimelineActiveClip* ptr = rows)
{
written = TablePtr->EngineTimelineEvaluateActive(_tl, t, ptr, (uint)count);
}
if (written != count)
{
throw new InvalidOperationException($"timeline_evaluate_active wrote {written}, expected {count}.");
}
return rows;
}
}
/// <summary>Track kinds mirroring ENGINE_TIMELINE_TRACK_* (#118 spec).</summary>
public enum TimelineTrackKind : uint
{
Transform = 0,
AnimClip = 1,
Audio = 2,
Activation = 3,
}
/// <summary>Transport states mirroring ENGINE_TIMELINE_* (#118 spec).</summary>
public enum TimelineState : int
{
Stopped = 0,
Playing = 1,
Paused = 2,
}
/// <summary>
/// Managed owner of a native sequencer player: transport cursor (play,
/// pause, stop, seek, time advance, rate, loop) bound to one Timeline.
/// The Timeline must outlive its players (native borrows, never owns).
/// </summary>
public sealed unsafe class TimelinePlayer : IDisposable
{
private NativeApi.EngineTimelinePlayer* _pl;
private bool _disposed;
public static IntPtr TableOverride
{
get => Timeline.TableOverride;
set => Timeline.TableOverride = value;
}
private static unsafe NativeApi.ApiTable* TablePtr =>
Timeline.TableOverride != IntPtr.Zero ? (NativeApi.ApiTable*)Timeline.TableOverride : NativeApi.ApiLoader.Table;
internal TimelinePlayer(NativeApi.EngineTimelinePlayer* handle)
{
_pl = handle;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
if (_pl != null)
{
TablePtr->EngineTimelinePlayerDestroy(_pl);
_pl = null;
}
}
}
/// <summary>Starts (or resumes) playback.</summary>
public void Play()
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelinePlayerPlay(_pl);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_play failed (rc {rc}).");
}
}
/// <summary>Freezes playback in place.</summary>
public void Pause()
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelinePlayerPause(_pl);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_pause failed (rc {rc}).");
}
}
/// <summary>Halts playback and rewinds to 0.</summary>
public void Stop()
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelinePlayerStop(_pl);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_stop failed (rc {rc}).");
}
}
/// <summary>Moves the cursor to t clamped into [0, duration].</summary>
public void Seek(float t)
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelinePlayerSeek(_pl, t);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_seek failed (rc {rc}).");
}
}
/// <summary>Advances the cursor by dt * rate while playing.</summary>
public void Advance(float dt)
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelinePlayerAdvance(_pl, dt);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_advance failed (rc {rc}).");
}
}
/// <summary>Current transport state.</summary>
public TimelineState State
{
get
{
ThrowIfDisposed();
int state;
var rc = TablePtr->EngineTimelinePlayerState(_pl, &state);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_state failed (rc {rc}).");
}
return (TimelineState)state;
}
}
/// <summary>Cursor time in [0, duration].</summary>
public float Time
{
get
{
ThrowIfDisposed();
float time;
var rc = TablePtr->EngineTimelinePlayerTime(_pl, &time);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_time failed (rc {rc}).");
}
return time;
}
}
/// <summary>Playback speed multiplier (&gt; 0).</summary>
public float Rate
{
get
{
ThrowIfDisposed();
float rate;
var rc = TablePtr->EngineTimelinePlayerRate(_pl, &rate);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_rate failed (rc {rc}).");
}
return rate;
}
set
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelinePlayerSetRate(_pl, value);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_set_rate failed (rc {rc}).");
}
}
}
/// <summary>Looping flag.</summary>
public bool Looping
{
get
{
ThrowIfDisposed();
int looping;
var rc = TablePtr->EngineTimelinePlayerLooping(_pl, &looping);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_looping failed (rc {rc}).");
}
return looping != 0;
}
set
{
ThrowIfDisposed();
var rc = TablePtr->EngineTimelinePlayerSetLooping(_pl, value ? 1 : 0);
if (rc != 0)
{
throw new InvalidOperationException($"timeline_player_set_looping failed (rc {rc}).");
}
}
}
}
+21 -2
View File
@@ -17,7 +17,7 @@ public static class AbiLayoutTests
{
var failures = 0;
failures += Check(NativeApi.AbiVersionMajor == 4, "abi major == 4");
failures += Check(NativeApi.AbiVersionMinor == 19, "abi minor == 19 (4.19: P5-04-followup renderer_attach_ui after 4.17 collision tail)");
failures += Check(NativeApi.AbiVersionMinor == 22, "abi minor == 22 (4.22: P5-07 playback tail after 4.21 sequencing)");
failures += Check(NativeApi.AbiVersionPatch == 0, "abi patch == 0");
failures += Check(NativeApi.ColumnStride == 64, "column stride == 64");
@@ -36,7 +36,7 @@ public static class AbiLayoutTests
failures += Check(sizeof(NativeApi.WorldTransform) == 64, "WorldTransform 64B");
failures += Check(sizeof(NativeApi.EcsChunkView) == 48, "EcsChunkView 48B");
failures += Check(sizeof(NativeApi.FrameDispatch) == 4, "FrameDispatch 4B");
failures += Check(sizeof(NativeApi.ApiTable) == 1008, "ApiTable 1008B (ABI 4.19: +1 renderer_attach_ui entry after 4.17 collision tail)");
failures += Check(sizeof(NativeApi.ApiTable) == 1200, "ApiTable 1200B (ABI 4.22: +24 sequencer entries after 4.19 HUD tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorDestroyEntity").ToInt64() == 672, "editor_destroy_entity @ 672 (ABI 4.10 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorSnapshotRestoreSubtree").ToInt64() == 704, "snapshot_restore_subtree @ 704 (ABI 4.10 tail end)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineAudioCreate").ToInt64() == 712, "audio_create @ 712 (ABI 4.11 tail)");
@@ -60,6 +60,22 @@ public static class AbiLayoutTests
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineSceneLoad").ToInt64() == 984, "scene_load @ 984 (ABI 4.16 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EnginePhysicsDrainEvents").ToInt64() == 992, "physics_drain_events @ 992 (ABI 4.17 tail end)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineRendererAttachUi").ToInt64() == 1000, "renderer_attach_ui @ 1000 (ABI 4.19 tail end)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineTimelineCreate").ToInt64() == 1008, "timeline_create @ 1008 (ABI 4.20 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineTimelineClipRemove").ToInt64() == 1048, "timeline_clip_remove @ 1048 (ABI 4.20 tail end)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineTimelineDuration").ToInt64() == 1056, "timeline_duration @ 1056 (ABI 4.21 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineTimelineEvaluateActive").ToInt64() == 1088, "timeline_evaluate_active @ 1088 (ABI 4.21 tail end)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineTimelinePlayerCreate").ToInt64() == 1096, "timeline_player_create @ 1096 (ABI 4.22 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineTimelinePlayerLooping").ToInt64() == 1192, "timeline_player_looping @ 1192 (ABI 4.22 tail end)");
failures += Check(sizeof(NativeApi.EngineTimelinePlayer) == 8, "EngineTimelinePlayer opaque 8B");
failures += Check(sizeof(NativeApi.TimelineActiveClip) == 16, "TimelineActiveClip 16B (P5-07 active row)");
failures += Check(Marshal.OffsetOf<NativeApi.TimelineActiveClip>("Clip").ToInt64() == 4, "TimelineActiveClip.Clip @ 4");
failures += Check(Marshal.OffsetOf<NativeApi.TimelineActiveClip>("Local").ToInt64() == 8, "TimelineActiveClip.Local @ 8");
failures += Check(Marshal.OffsetOf<NativeApi.TimelineActiveClip>("Weight").ToInt64() == 12, "TimelineActiveClip.Weight @ 12");
failures += Check(sizeof(NativeApi.EngineTimeline) == 8, "EngineTimeline opaque 8B");
failures += Check(sizeof(NativeApi.TimelineClip) == 32, "TimelineClip 32B (P5-07 clip row)");
failures += Check(Marshal.OffsetOf<NativeApi.TimelineClip>("SourceStart").ToInt64() == 8, "TimelineClip.SourceStart @ 8");
failures += Check(Marshal.OffsetOf<NativeApi.TimelineClip>("TargetEntity").ToInt64() == 16, "TimelineClip.TargetEntity @ 16");
failures += Check(Marshal.OffsetOf<NativeApi.TimelineClip>("AssetId").ToInt64() == 24, "TimelineClip.AssetId @ 24");
failures += Check(sizeof(NativeApi.InputState) == 1668, "InputState 1668B (P2-02 snapshot)");
failures += Check(sizeof(NativeApi.EngineRenderer) == 8, "EngineRenderer opaque 8B");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentRegister").ToInt64() == 312, "component_register @ 312 (ABI 4.2 tail)");
@@ -144,6 +160,9 @@ public static class AbiLayoutTests
failures += Check(src.Contains("unmanaged[Cdecl]<EngineWorld*, byte*, int> EngineSceneLoad", StringComparison.Ordinal), "scene_load present, plain Cdecl (file IO may block)");
failures += Check(src.Contains("unmanaged[Cdecl]<EnginePhysicsWorld*, CollisionEvent*, uint, uint> EnginePhysicsDrainEvents", StringComparison.Ordinal), "physics_drain_events present, plain Cdecl (drain may contend)");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineRenderer*, EngineUi*, int> EngineRendererAttachUi", StringComparison.Ordinal), "renderer_attach_ui present, plain Cdecl (frame submit may block)");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineTimeline*> EngineTimelineCreate", StringComparison.Ordinal), "timeline_create present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineTimelinePlayer*, int> EngineTimelinePlayerPlay", StringComparison.Ordinal), "timeline_player_play present, plain Cdecl");
failures += Check(!src.Contains("SuppressGCTransition]<EngineTimeline", StringComparison.Ordinal), "timeline entries NOT suppressed (may block)");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineWorld*, EngineEditorSession*> EngineEditorCreate", StringComparison.Ordinal), "editor_create present, plain Cdecl");
failures += Check(
src.Contains(
+1
View File
@@ -18,6 +18,7 @@ failures += Engine.Tests.CollisionTests.Run(args);
failures += Engine.Tests.AudioApiTests.Run(args);
failures += Engine.Tests.CharacterApiTests.Run(args);
failures += Engine.Tests.UiApiTests.Run(args);
failures += Engine.Tests.TimelineApiTests.Run(args);
failures += Engine.Tests.TilemapApiTests.Run();
failures += Engine.Tests.TerrainApiTests.Run(args);
+569
View File
@@ -0,0 +1,569 @@
// TimelineApiTests.cs — P5-07: managed sequencing/timing contract gate.
//
// Verifies the C# sequencing surface (ABI 4.19 over the 4.18 data model):
// * Type shape (no native needed): Timeline type shape (AddTrack/
// RemoveTrack/AddClip/RemoveClip/GetClip/Duration/Sort/Trim/
// EvaluateActive, IDisposable), TimelineTrackKind values, mirror sizes.
// * Managed-stub functional probe (no native needed): drive the Timeline
// wrapper through a managed ApiTable built from UnmanagedCallersOnly
// stubs, so the sequencing behavior (ordering, overlap, trim, eval)
// is verified through the real wrapper path.
// * Live native round-trip (skipped without engine_core): out-of-order
// adds + sort + overlap-winner eval + half-open end + trim clamp/drop
// through the REAL wrapper — mirrors the CTest acceptance.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Engine.Bindings;
using Engine.Runtime;
namespace Engine.Tests;
public static class TimelineApiTests
{
public static unsafe int Run(string[]? args = null)
{
var failures = 0;
// --- Type-shape contract (no native needed) ----------------------
failures += Check(typeof(Timeline).IsClass, "Timeline is a class (owns native handle)");
failures += Check(typeof(Timeline).GetMethod("AddTrack") != null, "Timeline.AddTrack exists");
failures += Check(typeof(Timeline).GetMethod("RemoveTrack") != null, "Timeline.RemoveTrack exists");
failures += Check(typeof(Timeline).GetMethod("AddClip") != null, "Timeline.AddClip exists");
failures += Check(typeof(Timeline).GetMethod("RemoveClip") != null, "Timeline.RemoveClip exists");
failures += Check(typeof(Timeline).GetMethod("GetClip") != null, "Timeline.GetClip exists");
failures += Check(typeof(Timeline).GetMethod("Sort") != null, "Timeline.Sort exists");
failures += Check(typeof(Timeline).GetMethod("Trim") != null, "Timeline.Trim exists");
failures += Check(typeof(Timeline).GetMethod("EvaluateActive") != null, "Timeline.EvaluateActive exists");
failures += Check(typeof(IDisposable).IsAssignableFrom(typeof(Timeline)), "Timeline IDisposable");
failures += Check((uint)TimelineTrackKind.Transform == 0, "Transform == 0");
failures += Check((uint)TimelineTrackKind.AnimClip == 1, "AnimClip == 1");
failures += Check((uint)TimelineTrackKind.Audio == 2, "Audio == 2");
failures += Check((uint)TimelineTrackKind.Activation == 3, "Activation == 3");
unsafe
{
failures += Check(sizeof(NativeApi.EngineTimeline) == 8, "NativeApi.EngineTimeline opaque 8B");
failures += Check(sizeof(NativeApi.TimelineClip) == 32, "NativeApi.TimelineClip 32B");
failures += Check(sizeof(NativeApi.TimelineActiveClip) == 16, "NativeApi.TimelineActiveClip 16B");
}
var fields = typeof(NativeApi.ApiTable).GetFields().Select(f => f.Name).ToArray();
foreach (var want in new[] { "EngineTimelineCreate", "EngineTimelineDestroy", "EngineTimelineTrackAdd", "EngineTimelineTrackRemove", "EngineTimelineClipAdd", "EngineTimelineClipRemove", "EngineTimelineDuration", "EngineTimelineClipGet", "EngineTimelineSort", "EngineTimelineTrim", "EngineTimelineEvaluateActive" })
{
failures += Check(fields.Contains(want), $"ApiTable.{want} exists");
}
// --- Managed-stub functional probe (no native needed) ------------
var priorOverride = Timeline.TableOverride;
try
{
unsafe
{
var stub = default(NativeApi.ApiTable);
stub.EngineTimelineCreate = &FakeSequencer.StubCreate;
stub.EngineTimelineDestroy = &FakeSequencer.StubDestroy;
stub.EngineTimelineTrackAdd = &FakeSequencer.StubTrackAdd;
stub.EngineTimelineTrackRemove = &FakeSequencer.StubTrackRemove;
stub.EngineTimelineClipAdd = &FakeSequencer.StubClipAdd;
stub.EngineTimelineClipRemove = &FakeSequencer.StubClipRemove;
stub.EngineTimelineDuration = &FakeSequencer.StubDuration;
stub.EngineTimelineClipGet = &FakeSequencer.StubClipGet;
stub.EngineTimelineSort = &FakeSequencer.StubSort;
stub.EngineTimelineTrim = &FakeSequencer.StubTrim;
stub.EngineTimelineEvaluateActive = &FakeSequencer.StubEvaluateActive;
stub.EngineTimelinePlayerCreate = &FakeSequencer.StubPlayerCreate;
stub.EngineTimelinePlayerDestroy = &FakeSequencer.StubPlayerDestroy;
stub.EngineTimelinePlayerPlay = &FakeSequencer.StubPlayerPlay;
stub.EngineTimelinePlayerPause = &FakeSequencer.StubPlayerPause;
stub.EngineTimelinePlayerStop = &FakeSequencer.StubPlayerStop;
stub.EngineTimelinePlayerSeek = &FakeSequencer.StubPlayerSeek;
stub.EngineTimelinePlayerAdvance = &FakeSequencer.StubPlayerAdvance;
stub.EngineTimelinePlayerState = &FakeSequencer.StubPlayerState;
stub.EngineTimelinePlayerTime = &FakeSequencer.StubPlayerTime;
stub.EngineTimelinePlayerSetRate = &FakeSequencer.StubPlayerSetRate;
stub.EngineTimelinePlayerRate = &FakeSequencer.StubPlayerRate;
stub.EngineTimelinePlayerSetLooping = &FakeSequencer.StubPlayerSetLooping;
stub.EngineTimelinePlayerLooping = &FakeSequencer.StubPlayerLooping;
var handle = GCHandle.Alloc(stub, GCHandleType.Pinned);
try
{
Timeline.TableOverride = (IntPtr)handle.AddrOfPinnedObject();
FakeSequencer.Reset();
using var tl = new Timeline();
failures += Check(FakeSequencer.CreateCount == 1, "stub create reached (count=1)");
var tr = tl.AddTrack(TimelineTrackKind.Transform);
failures += Check(tr == 1, "stub AddTrack returns 1");
var c0 = tl.AddClip(tr, 5.0f, 2.0f);
var c1 = tl.AddClip(tr, 0.0f, 3.0f);
failures += Check(c0 == 1 && c1 == 2, "stub AddClip ids 1,2");
var got = tl.GetClip(tr, c1);
failures += Check(got.Start == 0.0f && got.Duration == 3.0f, "stub GetClip values");
failures += Check(tl.Duration == 7.0f, $"stub Duration 7 (got {tl.Duration})");
tl.Sort();
failures += Check(FakeSequencer.Sorts == 1, "stub Sort reaches native entry");
var at1 = tl.EvaluateActive(1.0f);
failures += Check(at1.Length == 1 && at1[0].Local == 1.0f && at1[0].Weight == 1.0f, "stub eval t=1 local=1 w=1");
var at4 = tl.EvaluateActive(4.0f);
failures += Check(at4.Length == 0, "stub eval t=4 empty");
failures += Check(tl.Trim(6.0f) == 0, "stub trim 6 removes none");
failures += Check(tl.Trim(2.0f) == 1, "stub trim 2 removes one");
// --- Playback stub probe (state machine over the stub) --
failures += Check(typeof(TimelinePlayer).GetMethod("Play") != null, "TimelinePlayer.Play exists");
failures += Check(typeof(TimelinePlayer).GetMethod("Pause") != null, "TimelinePlayer.Pause exists");
failures += Check(typeof(TimelinePlayer).GetMethod("Stop") != null, "TimelinePlayer.Stop exists");
failures += Check(typeof(TimelinePlayer).GetMethod("Seek") != null, "TimelinePlayer.Seek exists");
failures += Check(typeof(TimelinePlayer).GetMethod("Advance") != null, "TimelinePlayer.Advance exists");
failures += Check((int)TimelineState.Stopped == 0 && (int)TimelineState.Playing == 1 && (int)TimelineState.Paused == 2, "TimelineState 0/1/2");
failures += Check(sizeof(NativeApi.EngineTimelinePlayer) == 8, "NativeApi.EngineTimelinePlayer opaque 8B");
foreach (var want in new[] { "EngineTimelinePlayerCreate", "EngineTimelinePlayerDestroy", "EngineTimelinePlayerPlay", "EngineTimelinePlayerPause", "EngineTimelinePlayerStop", "EngineTimelinePlayerSeek", "EngineTimelinePlayerAdvance", "EngineTimelinePlayerState", "EngineTimelinePlayerTime", "EngineTimelinePlayerSetRate", "EngineTimelinePlayerRate", "EngineTimelinePlayerSetLooping", "EngineTimelinePlayerLooping" })
{
failures += Check(fields.Contains(want), $"ApiTable.{want} exists");
}
using var pl = tl.CreatePlayer();
failures += Check(FakeSequencer.PlayerCreateCount == 1, "stub player_create reached (count=1)");
failures += Check(pl.State == TimelineState.Stopped && pl.Time == 0.0f, "stub player initial STOPPED @ 0");
pl.Play();
failures += Check(pl.State == TimelineState.Playing, "stub player PLAYING after play");
pl.Advance(1.5f);
failures += Check(pl.Time == 1.5f, $"stub player time 1.5 (got {pl.Time})");
pl.Pause();
failures += Check(pl.State == TimelineState.Paused, "stub player PAUSED after pause");
pl.Advance(1.0f);
failures += Check(pl.Time == 1.5f, "stub paused advance frozen");
pl.Seek(3.0f);
failures += Check(pl.Time == 3.0f, "stub player seek 3");
pl.Rate = 2.0f;
failures += Check(pl.Rate == 2.0f, "stub player rate 2");
pl.Looping = true;
failures += Check(pl.Looping, "stub player looping on");
pl.Play();
pl.Advance(1.0f);
failures += Check(pl.Time == 2.0f && pl.State == TimelineState.Playing, $"stub loop wrap 3+2 mod 3 = 2 (got {pl.Time})");
pl.Looping = false;
pl.Seek(6.5f);
failures += Check(pl.Time == 3.0f, $"stub seek clamps to duration 3 (got {pl.Time})");
pl.Advance(1.0f);
failures += Check(pl.Time == 3.0f && pl.State == TimelineState.Stopped, "stub non-loop pins + auto-stops");
pl.Play();
failures += Check(pl.Time == 0.0f && pl.State == TimelineState.Playing, "stub restart at 0");
pl.Stop();
failures += Check(pl.State == TimelineState.Stopped && pl.Time == 0.0f, "stub stop rewinds");
try { pl.Seek(float.NaN); failures += Check(false, "stub seek NaN must throw"); }
catch (InvalidOperationException) { failures += Check(true, "stub seek NaN throws"); }
try { pl.Advance(-1.0f); failures += Check(false, "stub advance -1 must throw"); }
catch (InvalidOperationException) { failures += Check(true, "stub advance -1 throws"); }
try { pl.Rate = 0.0f; failures += Check(false, "stub rate 0 must throw"); }
catch (InvalidOperationException) { failures += Check(true, "stub rate 0 throws"); }
tl.RemoveClip(tr, c0);
tl.RemoveTrack(tr);
failures += Check(FakeSequencer.Removes == 2, "stub removes reach native entries");
}
finally
{
Timeline.TableOverride = priorOverride;
handle.Free();
}
}
}
catch (Exception ex)
{
failures += Check(false, $"managed-stub probe threw: {ex.GetType().Name}: {ex.Message}");
Timeline.TableOverride = priorOverride;
}
// --- Live native round-trip (skipped without engine_core) --------
var liveProbe = (args ?? []).Contains("--live-native-probe");
try
{
var preloaded = TimelineLivePreload.TryPreloadEngineCoreHandle();
NativeApi.ApiTable* table;
var savedOverride = Timeline.TableOverride;
try
{
if (preloaded != IntPtr.Zero)
{
TimelineLivePreload.LoadIntoApiLoader(preloaded);
table = (NativeApi.ApiTable*)Timeline.TableOverride;
}
else if (liveProbe)
{
table = NativeApi.ApiLoader.Load();
}
else
{
Console.WriteLine("[timeline] live native probe skipped (engine_core not preloaded; pass --live-native-probe to force).");
return failures;
}
Timeline.TableOverride = (IntPtr)table;
using var tl = new Timeline();
var tr = tl.AddTrack(TimelineTrackKind.Transform);
var cLate = tl.AddClip(tr, 5.0f, 2.0f, targetEntity: 1);
var cEarly = tl.AddClip(tr, 0.0f, 3.0f, targetEntity: 1);
failures += Check(tl.Duration == 7.0f, $"live duration 7 (got {tl.Duration})");
var back = tl.GetClip(tr, cEarly);
failures += Check(back.Start == 0.0f && back.Duration == 3.0f, "live clip_get round-trip");
tl.Sort();
var at1 = tl.EvaluateActive(1.0f);
failures += Check(at1.Length == 1 && at1[0].Clip == cEarly && at1[0].Local == 1.0f && at1[0].Weight == 1.0f, "live eval t=1 early w=1");
failures += Check(tl.EvaluateActive(4.0f).Length == 0, "live eval t=4 gap empty");
var cMid = tl.AddClip(tr, 1.0f, 3.0f, targetEntity: 1);
tl.Sort();
var at2 = tl.EvaluateActive(2.0f);
failures += Check(at2.Length == 1 && at2[0].Clip == cMid, "live overlap later-start wins");
failures += Check(at2[0].Weight > 0.0f && at2[0].Weight < 1.0f, "live overlap weight shared");
var at3 = tl.EvaluateActive(3.0f);
failures += Check(at3.Length == 1 && at3[0].Clip == cMid, "live half-open end drops [0,3) at t=3");
failures += Check(tl.Trim(6.0f) == 0, "live trim 6 removes none");
failures += Check(tl.GetClip(tr, cLate).Duration == 1.0f, "live trim clamps straddler to 1");
failures += Check(tl.Trim(2.0f) == 1, "live trim 2 removes one");
// --- Live playback round-trip (same contract as CTest) -----
using var pl = tl.CreatePlayer();
failures += Check(pl.State == TimelineState.Stopped && pl.Time == 0.0f, "live player initial STOPPED @ 0");
pl.Play();
failures += Check(pl.State == TimelineState.Playing, "live player PLAYING after play");
pl.Advance(1.5f);
failures += Check(pl.Time == 1.5f, $"live player time 1.5 (got {pl.Time})");
pl.Pause();
failures += Check(pl.State == TimelineState.Paused, "live player PAUSED after pause");
pl.Advance(1.0f);
failures += Check(pl.Time == 1.5f, "live paused advance frozen");
pl.Looping = true;
pl.Rate = 2.0f;
failures += Check(pl.Looping && pl.Rate == 2.0f, "live rate 2 + loop on");
pl.Play();
pl.Advance(0.5f);
failures += Check(pl.Time == 0.5f && pl.State == TimelineState.Playing, $"live loop wrap 1.5+1 mod 2 = 0.5 (got {pl.Time})");
pl.Looping = false;
pl.Stop();
failures += Check(pl.State == TimelineState.Stopped && pl.Time == 0.0f, "live stop rewinds");
}
finally
{
Timeline.TableOverride = savedOverride;
}
}
catch (Exception ex)
{
failures += Check(false, $"live probe threw: {ex.GetType().Name}: {ex.Message}");
}
return failures;
}
private static int Check(bool ok, string name)
{
if (!ok)
{
Console.WriteLine($"[FAIL] {name}");
return 1;
}
Console.WriteLine($"[PASS] {name}");
return 0;
}
// Minimal in-memory sequencer behind the stub table: one track with a
// clip list; mirrors the native half-open/overlap/trim contract so the
// wrapper path is verified without native.
private static class FakeSequencer
{
public static int CreateCount;
public static int Sorts;
public static int Removes;
public static int PlayerCreateCount;
private static readonly List<(uint Id, float Start, float Duration)> Clips = new();
private static uint _nextClip = 1;
// Stub player cursor (mirrors the native ABI 4.20 state machine).
private static int _pState;
private static float _pTime;
private static float _pRate = 1.0f;
private static bool _pLoop;
public static void Reset()
{
CreateCount = 0;
Sorts = 0;
Removes = 0;
PlayerCreateCount = 0;
Clips.Clear();
_nextClip = 1;
_pState = 0;
_pTime = 0.0f;
_pRate = 1.0f;
_pLoop = false;
}
private static float StubDurationNow()
{
return Clips.Count == 0 ? 0 : Clips.Max(c => c.Start + c.Duration);
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe NativeApi.EngineTimelinePlayer* StubPlayerCreate(NativeApi.EngineTimeline* tl) { PlayerCreateCount++; return (NativeApi.EngineTimelinePlayer*)7; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe void StubPlayerDestroy(NativeApi.EngineTimelinePlayer* pl) { }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerPlay(NativeApi.EngineTimelinePlayer* pl)
{
if (_pState == 1) { return 0; }
var d = StubDurationNow();
if (d <= 0) { _pState = 0; _pTime = 0; return 0; }
if (_pState == 0 && _pTime >= d) { _pTime = 0; }
_pState = 1;
return 0;
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerPause(NativeApi.EngineTimelinePlayer* pl)
{
if (_pState == 1) { _pState = 2; }
return 0;
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerStop(NativeApi.EngineTimelinePlayer* pl) { _pState = 0; _pTime = 0; return 0; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerSeek(NativeApi.EngineTimelinePlayer* pl, float t)
{
if (!float.IsFinite(t)) { return -2; }
var d = StubDurationNow();
_pTime = t < 0 ? 0 : (t > d ? d : t);
return 0;
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerAdvance(NativeApi.EngineTimelinePlayer* pl, float dt)
{
if (!float.IsFinite(dt) || dt < 0) { return -2; }
if (_pState != 1) { return 0; }
var d = StubDurationNow();
if (d <= 0) { _pState = 0; _pTime = 0; return 0; }
_pTime += dt * _pRate;
if (_pTime < d) { return 0; }
if (_pLoop) { while (_pTime >= d) { _pTime -= d; } return 0; }
_pTime = d; _pState = 0;
return 0;
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerState(NativeApi.EngineTimelinePlayer* pl, int* outState) { *outState = _pState; return 0; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerTime(NativeApi.EngineTimelinePlayer* pl, float* outTime) { *outTime = _pTime; return 0; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerSetRate(NativeApi.EngineTimelinePlayer* pl, float rate)
{
if (!float.IsFinite(rate) || rate <= 0) { return -2; }
_pRate = rate;
return 0;
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerRate(NativeApi.EngineTimelinePlayer* pl, float* outRate) { *outRate = _pRate; return 0; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerSetLooping(NativeApi.EngineTimelinePlayer* pl, int looping) { _pLoop = looping != 0; return 0; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubPlayerLooping(NativeApi.EngineTimelinePlayer* pl, int* outLooping) { *outLooping = _pLoop ? 1 : 0; return 0; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe NativeApi.EngineTimeline* StubCreate() { CreateCount++; return (NativeApi.EngineTimeline*)1; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe void StubDestroy(NativeApi.EngineTimeline* tl) { }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe uint StubTrackAdd(NativeApi.EngineTimeline* tl, uint kind) => 1;
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubTrackRemove(NativeApi.EngineTimeline* tl, uint track) { Removes++; return 0; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe uint StubClipAdd(NativeApi.EngineTimeline* tl, uint track, NativeApi.TimelineClip* clip)
{
var id = _nextClip++;
Clips.Add((id, clip->Start, clip->Duration));
return id;
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubClipRemove(NativeApi.EngineTimeline* tl, uint track, uint clip) { Removes++; return 0; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubDuration(NativeApi.EngineTimeline* tl, float* outDuration)
{
*outDuration = Clips.Count == 0 ? 0 : Clips.Max(c => c.Start + c.Duration);
return 0;
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubClipGet(NativeApi.EngineTimeline* tl, uint track, uint clip, NativeApi.TimelineClip* outClip)
{
foreach (var c in Clips)
{
if (c.Id == clip)
{
*outClip = new NativeApi.TimelineClip { Start = c.Start, Duration = c.Duration };
return 0;
}
}
return -4;
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubSort(NativeApi.EngineTimeline* tl) { Sorts++; Clips.Sort((a, b) => a.Start.CompareTo(b.Start)); return 0; }
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubTrim(NativeApi.EngineTimeline* tl, float length)
{
var removed = Clips.RemoveAll(c => c.Start >= length);
return removed;
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe int StubEvaluateActive(NativeApi.EngineTimeline* tl, float t, NativeApi.TimelineActiveClip* outClips, uint capacity)
{
var live = Clips.Where(c => t >= c.Start && t < c.Start + c.Duration).ToList();
if (live.Count == 0 || outClips == null)
{
return live.Count;
}
var win = live.OrderByDescending(c => c.Start).First();
if ((uint)live.Count <= capacity)
{
outClips[0] = new NativeApi.TimelineActiveClip { Track = 1, Clip = win.Id, Local = t - win.Start, Weight = 1.0f };
}
return live.Count;
}
}
private static class TimelineLivePreload
{
public static IntPtr TryPreloadEngineCoreHandle()
{
try
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < 12 && dir != null; i++, dir = dir.Parent)
{
var key = dir.FullName;
if (!seen.Add(key))
{
continue;
}
if (File.Exists(Path.Combine(key, "Engine.slnx")))
{
foreach (var seedDir in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
{
foreach (var stagedName in new[] { "libengine_core.dylib", "engine_core.dll", "libengine_core.so", "libengine_core.so.0.1.0", "libengine_core.so.0" })
{
var staged = Path.Combine(seedDir, stagedName);
if (File.Exists(staged))
{
var h = PreloadPair(Path.GetFullPath(staged));
if (h != IntPtr.Zero)
{
return h;
}
}
}
}
return IntPtr.Zero;
}
}
}
catch (Exception)
{
}
return IntPtr.Zero;
}
public static unsafe void LoadIntoApiLoader(IntPtr handle)
{
if (!NativeLibrary.TryGetExport(handle, "engine_get_api_table", out var addr))
{
throw new EntryPointNotFoundException("engine_get_api_table not exported.");
}
var getTable = (delegate* unmanaged[Cdecl]<NativeApi.ApiTable*>)addr;
var table = getTable();
if (table == null)
{
throw new InvalidOperationException("engine_get_api_table returned null.");
}
if (table->Size < (ulong)sizeof(NativeApi.ApiTable))
{
throw new InvalidOperationException($"Native ABI table too small: {table->Size}.");
}
if (table->AbiVersionMajor != NativeApi.AbiVersionMajor || table->AbiVersionMinor != NativeApi.AbiVersionMinor || table->AbiVersionPatch != NativeApi.AbiVersionPatch)
{
throw new InvalidOperationException($"ABI version mismatch: native {table->AbiVersionMajor}.{table->AbiVersionMinor}.{table->AbiVersionPatch}.");
}
Timeline.TableOverride = (IntPtr)table;
}
private static IntPtr PreloadPair(string corePath)
{
try
{
var dir = Path.GetDirectoryName(corePath);
if (dir != null)
{
foreach (var flecsName in new[] { "libflecs.so", "libflecs.dylib", "flecs.dll" })
{
var flecs = Path.Combine(dir, flecsName);
if (File.Exists(flecs))
{
try
{
NativeLibrary.Load(flecs);
}
catch (Exception)
{
}
break;
}
}
}
}
catch (Exception)
{
}
try
{
return NativeLibrary.Load(corePath);
}
catch (Exception)
{
return IntPtr.Zero;
}
}
}
}
@@ -187,9 +187,37 @@ extern "C" {
// composites the HUD backbuffer as a textured overlay quad. Null clears
// the binding. All earlier entries keep order + signature; new fields
// extend the tail only.
//
// ABI 4.20.0: appends the P5-07 sequencer data-model tail
// (timeline_create/destroy/track_add/track_remove/clip_add/clip_remove)
// AFTER the 4.19 HUD-composite tail (renderer_attach_ui). A timeline
// owns tracks (transform / animation-clip / audio / activation kinds) and
// each track owns its clips (start time + duration + payload binding).
// All earlier entries keep order + signature; new fields extend the tail
// only.
//
// ABI 4.21.0: appends the P5-07 sequencer sequencing/timing tail
// (timeline_duration/timeline_clip_get/timeline_sort/timeline_trim/
// timeline_evaluate_active) AFTER the 4.20 data-model tail
// (timeline_clip_remove). Duration is the max clip end (0 when empty);
// clip_get reads one clip row back by handle; sort orders each track's
// clips by (start, clip id); trim cuts every clip to [0, length) (drops
// clips fully outside, clamps straddlers, duration <= 0 rejects);
// evaluate_active reports the clips live at time t ([start, end),
// later-starting clip first on overlap). All earlier entries keep order
// + signature; new fields extend the tail only.
//
// ABI 4.22.0: appends the P5-07 sequencer playback-control tail
// (timeline_player_create/destroy/play/pause/stop/seek/advance/state/time/
// set_rate/set_looping) AFTER the 4.21 sequencing tail
// (timeline_evaluate_active). A player binds one timeline (borrowed,
// never owned) and owns a transport cursor: state (stopped/playing/
// paused), time, rate, loop flag. play/pause/stop/seek/advance mutate the
// cursor; state/time/rate/looping read it back. All earlier entries keep
// order + signature; new fields extend the tail only.
// ---------------------------------------------------------------------------
#define ENGINE_ABI_VERSION_MAJOR 4
#define ENGINE_ABI_VERSION_MINOR 19
#define ENGINE_ABI_VERSION_MINOR 22
#define ENGINE_ABI_VERSION_PATCH 0
// 64-byte cache-line pitch for the component columns handed to managed
@@ -817,6 +845,216 @@ typedef struct EngineUi EngineUi;
typedef int32_t (*EngineRendererAttachUiFn)(EngineRenderer* renderer,
EngineUi* ui);
// ---------------------------------------------------------------------------
// P5-07 sequencer data-model tail (ABI 4.20): timeline tracks + clips.
//
// A timeline owns tracks; each track owns clips. Track kinds mirror the
// #118 spec: transform, animation clip, audio, activation. A clip places
// one payload on its track at `start` for `duration` seconds; the payload
// meaning depends on the track kind:
//
// transform: target_entity animated by position/rotation keyframes
// stored out of band (P5-07 sequencing slice evaluates).
// anim_clip: asset_id selects the AnimClip (P5-01 .anim artifact);
// source_start offsets into the clip.
// audio: asset_id selects the audio asset; source_start offsets.
// activation: target_entity toggles active over [start, start+duration).
//
// All entries are plain blocking Cdecl (never SuppressGCTransition).
// Opaque handle: EngineTimeline (owns tracks + clips; destroy frees it).
// Ids: track ids and clip ids are per-timeline 1-based handles
// (0 is reserved/invalid).
// ---------------------------------------------------------------------------
// Opaque sequencer timeline (owns tracks + clips).
typedef struct EngineTimeline EngineTimeline;
// Track kinds (mirror the #118 spec). Out-of-range kinds are rejected.
#define ENGINE_TIMELINE_TRACK_TRANSFORM 0
#define ENGINE_TIMELINE_TRACK_ANIM_CLIP 1
#define ENGINE_TIMELINE_TRACK_AUDIO 2
#define ENGINE_TIMELINE_TRACK_ACTIVATION 3
// One clip row: placement on its track + payload binding. 32 bytes:
// start/duration/source_start (finite, >= 0) + target entity + asset id.
typedef struct EngineTimelineClip {
float start;
float duration;
float source_start;
uint32_t reserved;
uint64_t target_entity;
uint64_t asset_id;
} EngineTimelineClip;
// Create/destroy a timeline. destroy is idempotent on NULL.
typedef EngineTimeline* (*EngineTimelineCreateFn)(void);
typedef void (*EngineTimelineDestroyFn)(EngineTimeline* timeline);
// Append one track of `kind` (ENGINE_TIMELINE_TRACK_*). Returns the new
// track id (> 0) or 0 on failure (null timeline, bad kind).
typedef uint32_t (*EngineTimelineTrackAddFn)(EngineTimeline* timeline,
uint32_t kind);
// Remove one track and all its clips. 0 on success, negative
// ENGINE_ABI_ERR_* on failure (null timeline, unknown track).
typedef int32_t (*EngineTimelineTrackRemoveFn)(EngineTimeline* timeline,
uint32_t track);
// Append one clip to `track`. start/duration must be finite with
// duration > 0; source_start must be finite and >= 0. Returns the new
// clip id (> 0) or 0 on failure.
typedef uint32_t (*EngineTimelineClipAddFn)(EngineTimeline* timeline,
uint32_t track,
const EngineTimelineClip* clip);
// Remove one clip from `track`. 0 on success, negative ENGINE_ABI_ERR_*
// on failure (null timeline/clip args, unknown track/clip).
typedef int32_t (*EngineTimelineClipRemoveFn)(EngineTimeline* timeline,
uint32_t track, uint32_t clip);
// ---------------------------------------------------------------------------
// P5-07 sequencer sequencing/timing tail (ABI 4.21): ordering, trim, and
// time queries over the 4.18 track/clip data model. Sequencing keeps clips
// sorted by start time (stable by clip id); overlap resolves to the
// later-starting clip (later clip id wins exact ties); trim cuts every
// clip to [0, length); evaluation reports the active clip set at time t.
//
// One active-clip row: which track/clip is live at t plus the local time.
// 16 bytes: track id + clip id + local (t - start) + weight (blend weight
// for the sequencing slice: 1 when a single clip is live, the overlap
// share when two overlap). All entries are plain blocking Cdecl (never
// SuppressGCTransition).
// ---------------------------------------------------------------------------
// One active clip at time t: track + clip handles, local time, weight.
typedef struct EngineTimelineActiveClip {
uint32_t track;
uint32_t clip;
float local;
float weight;
} EngineTimelineActiveClip;
// Timeline duration: max over tracks of max clip (start + duration).
// Writes 0 for an empty timeline. Returns 0 or ENGINE_ABI_ERR_*.
typedef int32_t (*EngineTimelineDurationFn)(const EngineTimeline* timeline,
float* out_duration);
// Read one clip row back by handle. Returns 0 or ENGINE_ABI_ERR_*
// (null args, unknown track/clip, tombstoned clip).
typedef int32_t (*EngineTimelineClipGetFn)(const EngineTimeline* timeline,
uint32_t track, uint32_t clip,
EngineTimelineClip* out);
// Sort every track's clips by (start, clip id). 0 on success, negative
// ENGINE_ABI_ERR_* on null timeline.
typedef int32_t (*EngineTimelineSortFn)(EngineTimeline* timeline);
// Trim every clip to [0, length): clips with start >= length are removed,
// clips straddling length are clamped (duration = length - start), and
// clips already inside are untouched. length must be finite and > 0.
// Returns the number of clips removed, or negative ENGINE_ABI_ERR_* on
// null timeline / bad length.
typedef int32_t (*EngineTimelineTrimFn)(EngineTimeline* timeline,
float length);
// Evaluate the timeline at time t (finite, >= 0): for each track with a
// clip live at t ([start, start + duration)), report at most one row —
// the live clip with the latest start (latest clip id on exact ties).
// Rows are written sorted by (track id). weight is 1 when the reported
// clip is the only live clip on its track, else the overlap share
// (reported duration / sum of live durations on that track). Returns the
// rows written, or negative ENGINE_ABI_ERR_* on null args / bad t.
// capacity 0 writes nothing and returns the live count (size query).
typedef int32_t (*EngineTimelineEvaluateActiveFn)(
const EngineTimeline* timeline, float t, EngineTimelineActiveClip* out,
uint32_t capacity);
// ---------------------------------------------------------------------------
// P5-07 sequencer playback-control tail (ABI 4.22): transport state machine
// over the 4.18 track/clip data model + 4.19 sequencing queries.
//
// A player binds exactly one timeline at creation (borrowed, never owned:
// destroying the timeline while a player still references it is a caller
// bug — the player does NOT free it, and destroy is idempotent on NULL).
// The player owns the transport cursor only:
//
// state: STOPPED (initial) / PLAYING / PAUSED. play from STOPPED starts
// at 0 when the cursor is at/past duration; play from PAUSED
// resumes in place; pause freezes in place (PLAYING -> PAUSED,
// no-op otherwise); stop halts and rewinds (time = 0, STOPPED).
// time: cursor seconds in [0, duration]. seek clamps into range
// (negative -> 0, past end -> duration); advance(dt) moves the
// cursor only while PLAYING (dt may be 0; negative dt is
// rejected). When the cursor reaches duration: non-looping
// players auto-stop (state STOPPED, time pinned at duration);
// looping players wrap (time modulo duration, stays PLAYING).
// An empty (duration 0) timeline never leaves STOPPED.
// rate: playback speed multiplier (> 0, finite; default 1). The
// advance step is dt * rate (rate applied once, on advance).
// loop: looping flag (0 = off, nonzero = on; default off).
//
// Duration is the live 4.19 timeline_duration at the moment of the call
// (clip edits between calls take effect on the next play/advance/seek).
//
// State values mirror the #118 transport spec. All entries are plain
// blocking Cdecl (never SuppressGCTransition).
// ---------------------------------------------------------------------------
// Opaque sequencer player (binds one timeline, owns the cursor).
typedef struct EngineTimelinePlayer EngineTimelinePlayer;
// Transport states (mirror the #118 spec). Out-of-range states never occur.
#define ENGINE_TIMELINE_STOPPED 0
#define ENGINE_TIMELINE_PLAYING 1
#define ENGINE_TIMELINE_PAUSED 2
// Creates a player bound to `timeline` (must be non-NULL; cursor starts
// STOPPED at time 0, rate 1, looping off). Returns NULL on failure.
typedef EngineTimelinePlayer* (*EngineTimelinePlayerCreateFn)(
EngineTimeline* timeline);
// Destroys a player (frees the cursor only, never the timeline).
// Idempotent on NULL.
typedef void (*EngineTimelinePlayerDestroyFn)(EngineTimelinePlayer* player);
// Starts (or resumes) playback. 0 on success, negative ENGINE_ABI_ERR_*
// on null player. From STOPPED at/past duration restarts at 0; from
// PAUSED resumes in place; PLAYING is a no-op success.
typedef int32_t (*EngineTimelinePlayerPlayFn)(EngineTimelinePlayer* player);
// Freezes playback in place. 0 on success, negative ENGINE_ABI_ERR_*
// on null player. PLAYING -> PAUSED; STOPPED/PAUSED are no-op success.
typedef int32_t (*EngineTimelinePlayerPauseFn)(EngineTimelinePlayer* player);
// Halts playback and rewinds (time = 0, state STOPPED). 0 on success,
// negative ENGINE_ABI_ERR_* on null player.
typedef int32_t (*EngineTimelinePlayerStopFn)(EngineTimelinePlayer* player);
// Moves the cursor to t clamped into [0, duration]. Preserves state
// (seek while PLAYING keeps playing from t). 0 on success, negative
// ENGINE_ABI_ERR_* on null player / non-finite t.
typedef int32_t (*EngineTimelinePlayerSeekFn)(EngineTimelinePlayer* player,
float t);
// Advances the cursor by dt * rate while PLAYING (dt >= 0, finite).
// PAUSED/STOPPED players ignore advance (cursor unmoved, still 0).
// 0 on success, negative ENGINE_ABI_ERR_* on null player / bad dt.
typedef int32_t (*EngineTimelinePlayerAdvanceFn)(
EngineTimelinePlayer* player, float dt);
// Reads the transport state (ENGINE_TIMELINE_*). 0 or ENGINE_ABI_ERR_*.
typedef int32_t (*EngineTimelinePlayerStateFn)(
const EngineTimelinePlayer* player, int32_t* out_state);
// Reads the cursor time in [0, duration]. 0 or ENGINE_ABI_ERR_*.
typedef int32_t (*EngineTimelinePlayerTimeFn)(
const EngineTimelinePlayer* player, float* out_time);
// Sets the rate multiplier (finite, > 0). 0 or ENGINE_ABI_ERR_*.
typedef int32_t (*EngineTimelinePlayerSetRateFn)(
EngineTimelinePlayer* player, float rate);
// Reads the rate multiplier. 0 or ENGINE_ABI_ERR_*.
typedef int32_t (*EngineTimelinePlayerRateFn)(
const EngineTimelinePlayer* player, float* out_rate);
// Sets looping (0 = off, nonzero = on). 0 or ENGINE_ABI_ERR_*.
typedef int32_t (*EngineTimelinePlayerSetLoopingFn)(
EngineTimelinePlayer* player, int32_t looping);
// Reads looping (0/1 into *out_looping). 0 or ENGINE_ABI_ERR_*.
typedef int32_t (*EngineTimelinePlayerLoopingFn)(
const EngineTimelinePlayer* player, int32_t* out_looping);
// ---------------------------------------------------------------------------
// P5-04 game UI tail (ABI 4.14): RmlUi + FreeType HUD, CPU-rendered.
//
@@ -1554,6 +1792,35 @@ typedef struct EngineApiTable {
EnginePhysicsDrainEventsFn physics_drain_events;
// P5-04-followup HUD-composite tail (ABI 4.19). All earlier order frozen.
EngineRendererAttachUiFn renderer_attach_ui;
// P5-07 sequencer data-model tail (ABI 4.20). All earlier order frozen.
EngineTimelineCreateFn timeline_create;
EngineTimelineDestroyFn timeline_destroy;
EngineTimelineTrackAddFn timeline_track_add;
EngineTimelineTrackRemoveFn timeline_track_remove;
EngineTimelineClipAddFn timeline_clip_add;
EngineTimelineClipRemoveFn timeline_clip_remove;
// P5-07 sequencer sequencing/timing tail (ABI 4.21). All earlier order
// frozen.
EngineTimelineDurationFn timeline_duration;
EngineTimelineClipGetFn timeline_clip_get;
EngineTimelineSortFn timeline_sort;
EngineTimelineTrimFn timeline_trim;
EngineTimelineEvaluateActiveFn timeline_evaluate_active;
// P5-07 sequencer playback-control tail (ABI 4.22). All earlier order
// frozen.
EngineTimelinePlayerCreateFn timeline_player_create;
EngineTimelinePlayerDestroyFn timeline_player_destroy;
EngineTimelinePlayerPlayFn timeline_player_play;
EngineTimelinePlayerPauseFn timeline_player_pause;
EngineTimelinePlayerStopFn timeline_player_stop;
EngineTimelinePlayerSeekFn timeline_player_seek;
EngineTimelinePlayerAdvanceFn timeline_player_advance;
EngineTimelinePlayerStateFn timeline_player_state;
EngineTimelinePlayerTimeFn timeline_player_time;
EngineTimelinePlayerSetRateFn timeline_player_set_rate;
EngineTimelinePlayerRateFn timeline_player_rate;
EngineTimelinePlayerSetLoopingFn timeline_player_set_looping;
EngineTimelinePlayerLoopingFn timeline_player_looping;
} EngineApiTable;
+613
View File
@@ -158,6 +158,19 @@ static_assert(offsetof(EngineCharacterState, grounded) == 12, "EngineCharacterSt
static_assert(offsetof(EngineCharacterState, ground_y) == 16, "EngineCharacterState.ground_y @ 16");
static_assert(offsetof(EngineCharacterState, vy) == 20, "EngineCharacterState.vy @ 20");
// ABI 4.20 sequencer data-model tail: clip row 32B. Field offsets pin the
// C# mirror layout (Timeline.cs in the sequencing slice).
static_assert(sizeof(EngineTimelineClip) == 32, "EngineTimelineClip must be 32 bytes");
static_assert(offsetof(EngineTimelineClip, source_start) == 8, "EngineTimelineClip.source_start @ 8");
static_assert(offsetof(EngineTimelineClip, target_entity) == 16, "EngineTimelineClip.target_entity @ 16");
static_assert(offsetof(EngineTimelineClip, asset_id) == 24, "EngineTimelineClip.asset_id @ 24");
// ABI 4.21 sequencing tail: active-clip row 16B. Field offsets pin the
// C# mirror (Timeline.cs in the playback slice; TimelineActiveClip here).
static_assert(sizeof(EngineTimelineActiveClip) == 16, "EngineTimelineActiveClip must be 16 bytes");
static_assert(offsetof(EngineTimelineActiveClip, clip) == 4, "EngineTimelineActiveClip.clip @ 4");
static_assert(offsetof(EngineTimelineActiveClip, local) == 8, "EngineTimelineActiveClip.local @ 8");
static_assert(offsetof(EngineTimelineActiveClip, weight) == 12, "EngineTimelineActiveClip.weight @ 12");
namespace {
// One registered managed system: C# callback + opaque context.
@@ -1726,6 +1739,582 @@ auto ApiLogWrite(int32_t level, const char* msg) -> int32_t {
return WorldLog().write(level, msg);
}
// P5-07 sequencer data-model tail (ABI 4.20): timeline tracks + clips.
// The timeline owns tracks; each track owns clips (ids are 1-based
// handles: index + 1, 0 reserved/invalid). Slot reuse: removed ids become
// tombstones (kind kInvalid / id 0) and are recycled by the next add —
// ids therefore stay small and stable for editor scrub panels. Callers
// must NOT cache raw pointers (a vector reallocation may move storage);
// the ABI surface is handles only. All functions are noexcept and plain
// blocking Cdecl; evaluation/scrub is the sequencing slice's job.
namespace {
constexpr std::uint32_t kTimelineTrackInvalid = 4;
struct TimelineClipRow {
EngineTimelineClip clip{};
std::uint32_t id{0}; // 0 = tombstone
};
struct TimelineTrackRow {
std::uint32_t id{0}; // 0 = tombstone
std::uint32_t kind{kTimelineTrackInvalid};
std::uint32_t next_clip{1};
std::vector<TimelineClipRow> clips;
};
struct EngineTimelineImpl {
std::uint32_t next_track{1};
std::vector<TimelineTrackRow> tracks;
};
auto TimelineImplOf(EngineTimeline* timeline) noexcept
-> EngineTimelineImpl* {
return reinterpret_cast<EngineTimelineImpl*>(timeline); // NOLINT
}
auto FindTrack(EngineTimelineImpl* impl, std::uint32_t track) noexcept
-> TimelineTrackRow* {
if (impl == nullptr || track == 0) {
return nullptr;
}
for (auto& row : impl->tracks) {
if (row.id == track) {
return &row;
}
}
return nullptr;
}
} // namespace
auto ApiTimelineCreate() -> EngineTimeline* {
// Opaque C-ABI sequencer handle: C# owns it, timeline_destroy frees it
// (same model as the M1-04 worlds).
auto* impl = new (std::nothrow) EngineTimelineImpl(); // NOSONAR(cpp:S5025) C-ABI ownership transfer: C# owns the handle, paired delete in timeline_destroy.
if (impl == nullptr) {
return nullptr;
}
return reinterpret_cast<EngineTimeline*>(impl); // NOLINT
}
auto ApiTimelineDestroy(EngineTimeline* timeline) -> void {
if (timeline == nullptr) {
return;
}
delete TimelineImplOf(timeline); // NOLINT NOSONAR(cpp:S5025) paired cleanup of the C-ABI handle above.
}
auto ApiTimelineTrackAdd(EngineTimeline* timeline, std::uint32_t kind)
-> std::uint32_t {
auto* impl = TimelineImplOf(timeline);
if (impl == nullptr || kind > ENGINE_TIMELINE_TRACK_ACTIVATION) {
return 0;
}
for (auto& row : impl->tracks) {
if (row.id == 0) {
row.id = impl->next_track++;
row.kind = kind;
row.next_clip = 1;
row.clips.clear();
return row.id;
}
}
TimelineTrackRow row;
row.id = impl->next_track++;
row.kind = kind;
impl->tracks.push_back(row);
return row.id;
}
auto ApiTimelineTrackRemove(EngineTimeline* timeline, std::uint32_t track)
-> int32_t {
auto* impl = TimelineImplOf(timeline);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
auto* row = FindTrack(impl, track);
if (row == nullptr) {
return ENGINE_ABI_ERR_NOT_FOUND;
}
row->id = 0;
row->kind = kTimelineTrackInvalid;
row->clips.clear();
return 0;
}
auto ApiTimelineClipAdd(EngineTimeline* timeline, std::uint32_t track,
const EngineTimelineClip* clip) -> std::uint32_t {
auto* impl = TimelineImplOf(timeline);
if (impl == nullptr || clip == nullptr) {
return 0;
}
auto* row = FindTrack(impl, track);
if (row == nullptr) {
return 0;
}
if (!std::isfinite(clip->start) || !std::isfinite(clip->duration) ||
!std::isfinite(clip->source_start) || clip->start < 0.0F ||
clip->duration <= 0.0F || clip->source_start < 0.0F) {
return 0;
}
for (auto& crow : row->clips) {
if (crow.id == 0) {
crow.id = row->next_clip++;
crow.clip = *clip;
return crow.id;
}
}
TimelineClipRow crow;
crow.id = row->next_clip++;
crow.clip = *clip;
row->clips.push_back(crow);
return crow.id;
}
auto ApiTimelineClipRemove(EngineTimeline* timeline, std::uint32_t track,
std::uint32_t clip) -> int32_t {
auto* impl = TimelineImplOf(timeline);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
auto* row = FindTrack(impl, track);
if (row == nullptr || clip == 0) {
return ENGINE_ABI_ERR_NOT_FOUND;
}
for (auto& crow : row->clips) {
if (crow.id == clip) {
crow.id = 0;
return 0;
}
}
return ENGINE_ABI_ERR_NOT_FOUND;
}
// Tombstones (id 0) sink to the tail; live rows order by (start, id).
auto TimelinePrevAfter(const TimelineClipRow& prev,
const TimelineClipRow& key) noexcept -> bool {
if (key.id == 0) {
return prev.id != 0;
}
if (prev.id == 0) {
return false;
}
if (prev.clip.start != key.clip.start) {
return prev.clip.start > key.clip.start;
}
return prev.id > key.id;
}
// P5-07 sequencing/timing tail (ABI 4.21): ordering, trim, time queries.
// Clip liveness is the half-open window [start, start + duration); a clip
// ending exactly at t is NOT live at t. Overlap resolves to the live clip
// with the latest start (latest clip id breaks exact ties), matching the
// #118 spec (later-placed clip wins its window).
namespace {
auto FindClip(const TimelineTrackRow* row, std::uint32_t clip) noexcept
-> const TimelineClipRow* {
if (row == nullptr || clip == 0) {
return nullptr;
}
for (const auto& crow : row->clips) {
if (crow.id == clip) {
return &crow;
}
}
return nullptr;
}
auto ClipEnd(const EngineTimelineClip& clip) noexcept -> float {
return clip.start + clip.duration;
}
auto ClipLiveAt(const EngineTimelineClip& clip, float t) noexcept -> bool {
return t >= clip.start && t < ClipEnd(clip);
}
} // namespace
auto ApiTimelineDuration(const EngineTimeline* timeline,
float* out_duration) -> int32_t {
if (timeline == nullptr || out_duration == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
const auto* impl =
reinterpret_cast<const EngineTimelineImpl*>(timeline); // NOLINT
float max_end = 0.0F;
for (const auto& row : impl->tracks) {
if (row.id == 0) {
continue;
}
for (const auto& crow : row.clips) {
if (crow.id == 0) {
continue;
}
const float end = ClipEnd(crow.clip);
if (end > max_end) {
max_end = end;
}
}
}
*out_duration = max_end;
return 0;
}
auto ApiTimelineClipGet(const EngineTimeline* timeline, std::uint32_t track,
std::uint32_t clip, EngineTimelineClip* out)
-> int32_t {
if (timeline == nullptr || out == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
const auto* impl =
reinterpret_cast<const EngineTimelineImpl*>(timeline); // NOLINT
const TimelineTrackRow* row = nullptr;
for (const auto& cand : impl->tracks) {
if (cand.id == track) {
row = &cand;
break;
}
}
if (row == nullptr || track == 0) {
return ENGINE_ABI_ERR_NOT_FOUND;
}
const auto* crow = FindClip(row, clip);
if (crow == nullptr) {
return ENGINE_ABI_ERR_NOT_FOUND;
}
*out = crow->clip;
return 0;
}
auto ApiTimelineSort(EngineTimeline* timeline) -> int32_t {
auto* impl = TimelineImplOf(timeline);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
for (auto& row : impl->tracks) {
if (row.id == 0) {
continue;
}
// Insertion sort by (start, clip id): track clip counts are editor
// scale, and this keeps the ordering stable without <algorithm>.
for (std::size_t i = 1; i < row.clips.size(); ++i) {
TimelineClipRow key = row.clips[i];
std::size_t j = i;
while (j > 0 && TimelinePrevAfter(row.clips[j - 1], key)) {
row.clips[j] = row.clips[j - 1];
--j;
}
row.clips[j] = key;
}
}
return 0;
}
auto ApiTimelineTrim(EngineTimeline* timeline, float length) -> int32_t {
auto* impl = TimelineImplOf(timeline);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (!std::isfinite(length) || length <= 0.0F) {
return ENGINE_ABI_ERR_BAD_ARG;
}
int32_t removed = 0;
for (auto& row : impl->tracks) {
if (row.id == 0) {
continue;
}
for (auto& crow : row.clips) {
if (crow.id == 0) {
continue;
}
if (crow.clip.start >= length) {
crow.id = 0; // fully outside [0, length): drop
++removed;
} else if (ClipEnd(crow.clip) > length) {
crow.clip.duration = length - crow.clip.start; // straddler: clamp
}
}
}
return removed;
}
// Latest-starting live clip wins the track's window; exact start ties go
// to the latest clip id (clip_add order). Returns the winner (nullptr when
// none live) plus the live-duration sum for weight normalization.
struct TimelineWinner {
const TimelineClipRow* row{nullptr};
float live_duration_sum{0.0F};
};
auto TimelineTrackWinner(const TimelineTrackRow& row, float t) noexcept
-> TimelineWinner {
TimelineWinner w;
for (const auto& crow : row.clips) {
if (crow.id == 0 || !ClipLiveAt(crow.clip, t)) {
continue;
}
w.live_duration_sum += crow.clip.duration;
if (w.row == nullptr || crow.clip.start > w.row->clip.start ||
(crow.clip.start == w.row->clip.start && crow.id > w.row->id)) {
w.row = &crow;
}
}
return w;
}
auto ApiTimelineEvaluateActive(const EngineTimeline* timeline, float t,
EngineTimelineActiveClip* out,
std::uint32_t capacity) -> int32_t {
if (timeline == nullptr || (out == nullptr && capacity != 0)) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (!std::isfinite(t) || t < 0.0F) {
return ENGINE_ABI_ERR_BAD_ARG;
}
const auto* impl =
reinterpret_cast<const EngineTimelineImpl*>(timeline); // NOLINT
int32_t live_tracks = 0;
for (const auto& row : impl->tracks) {
if (row.id == 0) {
continue;
}
const TimelineWinner w = TimelineTrackWinner(row, t);
if (w.row == nullptr) {
continue;
}
float weight = 1.0F;
if (w.live_duration_sum > 0.0F) {
weight = w.row->clip.duration / w.live_duration_sum;
}
if (static_cast<std::uint32_t>(live_tracks) < capacity && out != nullptr) {
out[live_tracks].track = row.id;
out[live_tracks].clip = w.row->id;
out[live_tracks].local = t - w.row->clip.start;
out[live_tracks].weight = weight;
}
++live_tracks;
}
return live_tracks;
}
// P5-07 playback-control tail (ABI 4.22): transport state machine. The
// player binds one timeline (borrowed, never owned) and owns the cursor
// only: STOPPED/PLAYING/PAUSED + time + rate + loop. Duration is always
// the LIVE 4.19 timeline_duration at call time (clip edits between calls
// take effect on the next play/seek/advance), so helpers recompute it
// per call rather than caching it at player_create.
namespace {
struct EngineTimelinePlayerImpl {
const EngineTimeline* timeline{nullptr};
int32_t state{ENGINE_TIMELINE_STOPPED};
float time{0.0F};
float rate{1.0F};
bool looping{false};
};
auto PlayerImplOf(EngineTimelinePlayer* player) noexcept
-> EngineTimelinePlayerImpl* {
return reinterpret_cast<EngineTimelinePlayerImpl*>(player); // NOLINT
}
auto PlayerImplOf(const EngineTimelinePlayer* player) noexcept
-> const EngineTimelinePlayerImpl* {
return reinterpret_cast<const EngineTimelinePlayerImpl*>(player); // NOLINT
}
// Live duration of the bound timeline (0 on any failure; duration is a
// max clip end so it is always >= 0 for a valid timeline).
auto PlayerDuration(const EngineTimelinePlayerImpl* impl) noexcept -> float {
if (impl == nullptr || impl->timeline == nullptr) {
return 0.0F;
}
float duration = 0.0F;
if (ApiTimelineDuration(impl->timeline, &duration) != 0) {
return 0.0F;
}
return duration < 0.0F ? 0.0F : duration;
}
} // namespace
auto ApiTimelinePlayerCreate(EngineTimeline* timeline)
-> EngineTimelinePlayer* {
// Opaque sequencer player: C# owns it, timeline_player_destroy frees
// the cursor only (never the timeline).
if (timeline == nullptr) {
return nullptr;
}
auto* impl = new (std::nothrow) EngineTimelinePlayerImpl(); // NOSONAR(cpp:S5025) C-ABI ownership transfer: C# owns the cursor, paired delete in destroy.
if (impl == nullptr) {
return nullptr;
}
impl->timeline = timeline;
return reinterpret_cast<EngineTimelinePlayer*>(impl); // NOLINT
}
auto ApiTimelinePlayerDestroy(EngineTimelinePlayer* player) -> void {
if (player == nullptr) {
return;
}
delete PlayerImplOf(player); // NOLINT NOSONAR(cpp:S5025) paired cleanup of the C-ABI cursor above.
}
auto ApiTimelinePlayerPlay(EngineTimelinePlayer* player) -> int32_t {
auto* impl = PlayerImplOf(player);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (impl->state == ENGINE_TIMELINE_PLAYING) {
return 0;
}
const float duration = PlayerDuration(impl);
if (duration <= 0.0F) {
// Empty timeline: nothing to play; stay STOPPED at 0.
impl->state = ENGINE_TIMELINE_STOPPED;
impl->time = 0.0F;
return 0;
}
if (impl->state == ENGINE_TIMELINE_STOPPED && impl->time >= duration) {
impl->time = 0.0F; // restart from the top
}
impl->state = ENGINE_TIMELINE_PLAYING;
return 0;
}
auto ApiTimelinePlayerPause(EngineTimelinePlayer* player) -> int32_t {
auto* impl = PlayerImplOf(player);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (impl->state == ENGINE_TIMELINE_PLAYING) {
impl->state = ENGINE_TIMELINE_PAUSED;
}
return 0;
}
auto ApiTimelinePlayerStop(EngineTimelinePlayer* player) -> int32_t {
auto* impl = PlayerImplOf(player);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
impl->state = ENGINE_TIMELINE_STOPPED;
impl->time = 0.0F;
return 0;
}
auto ApiTimelinePlayerSeek(EngineTimelinePlayer* player, float t) -> int32_t {
auto* impl = PlayerImplOf(player);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (!std::isfinite(t)) {
return ENGINE_ABI_ERR_BAD_ARG;
}
const float duration = PlayerDuration(impl);
float clamped = t < 0.0F ? 0.0F : t;
if (clamped > duration) {
clamped = duration;
}
impl->time = clamped; // state preserved: seek while PLAYING keeps playing
return 0;
}
auto ApiTimelinePlayerAdvance(EngineTimelinePlayer* player, float dt)
-> int32_t {
auto* impl = PlayerImplOf(player);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (!std::isfinite(dt) || dt < 0.0F) {
return ENGINE_ABI_ERR_BAD_ARG;
}
if (impl->state != ENGINE_TIMELINE_PLAYING) {
return 0; // PAUSED/STOPPED ignore advance
}
const float duration = PlayerDuration(impl);
if (duration <= 0.0F) {
impl->state = ENGINE_TIMELINE_STOPPED;
impl->time = 0.0F;
return 0;
}
impl->time += dt * impl->rate;
if (impl->time < duration) {
return 0;
}
if (impl->looping) {
// Wrap modulo duration (dt may span several loops); stays PLAYING.
// duration > 0 here, so the loop terminates.
while (impl->time >= duration) {
impl->time -= duration;
}
return 0;
}
impl->time = duration; // pin at the end and auto-stop
impl->state = ENGINE_TIMELINE_STOPPED;
return 0;
}
auto ApiTimelinePlayerState(const EngineTimelinePlayer* player,
int32_t* out_state) -> int32_t {
if (player == nullptr || out_state == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
*out_state = PlayerImplOf(player)->state;
return 0;
}
auto ApiTimelinePlayerTime(const EngineTimelinePlayer* player,
float* out_time) -> int32_t {
if (player == nullptr || out_time == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
*out_time = PlayerImplOf(player)->time;
return 0;
}
auto ApiTimelinePlayerSetRate(EngineTimelinePlayer* player, float rate)
-> int32_t {
auto* impl = PlayerImplOf(player);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
if (!std::isfinite(rate) || rate <= 0.0F) {
return ENGINE_ABI_ERR_BAD_ARG;
}
impl->rate = rate;
return 0;
}
auto ApiTimelinePlayerRate(const EngineTimelinePlayer* player,
float* out_rate) -> int32_t {
if (player == nullptr || out_rate == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
*out_rate = PlayerImplOf(player)->rate;
return 0;
}
auto ApiTimelinePlayerSetLooping(EngineTimelinePlayer* player, int32_t looping)
-> int32_t {
auto* impl = PlayerImplOf(player);
if (impl == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
impl->looping = looping != 0;
return 0;
}
auto ApiTimelinePlayerLooping(const EngineTimelinePlayer* player,
int32_t* out_looping) -> int32_t {
if (player == nullptr || out_looping == nullptr) {
return ENGINE_ABI_ERR_NULL_ARG;
}
*out_looping = PlayerImplOf(player)->looping ? 1 : 0;
return 0;
}
auto ApiLogSetSink(EngineWorld* world, EngineLogSinkCallback sink, void* user_data)
-> int32_t {
if (world == nullptr) {
@@ -2737,6 +3326,30 @@ const EngineApiTable kApiTable = {
&ApiSceneLoad,
&ApiPhysicsDrainEvents,
&ApiRendererAttachUi,
&ApiTimelineCreate,
&ApiTimelineDestroy,
&ApiTimelineTrackAdd,
&ApiTimelineTrackRemove,
&ApiTimelineClipAdd,
&ApiTimelineClipRemove,
&ApiTimelineDuration,
&ApiTimelineClipGet,
&ApiTimelineSort,
&ApiTimelineTrim,
&ApiTimelineEvaluateActive,
&ApiTimelinePlayerCreate,
&ApiTimelinePlayerDestroy,
&ApiTimelinePlayerPlay,
&ApiTimelinePlayerPause,
&ApiTimelinePlayerStop,
&ApiTimelinePlayerSeek,
&ApiTimelinePlayerAdvance,
&ApiTimelinePlayerState,
&ApiTimelinePlayerTime,
&ApiTimelinePlayerSetRate,
&ApiTimelinePlayerRate,
&ApiTimelinePlayerSetLooping,
&ApiTimelinePlayerLooping,
};
} // namespace
+136 -1
View File
@@ -168,6 +168,102 @@ void CountingCallback(const EcsChunkView* view, float dt) {
} // namespace
// P5-07 sequencer tail checks (ABI 4.20-4.22): offsets, enums, playback
// round-trip + row mirrors. Split from main to keep complexity in budget.
auto CheckTimelineTail(const EngineApiTable* api) -> int {
int failures = 0;
failures += check(offsetof(EngineApiTable, timeline_player_create) == 1096,
"abi: timeline_player_create @ 1096 (4.22 tail)");
failures += check(offsetof(EngineApiTable, timeline_player_looping) == 1192,
"abi: timeline_player_looping @ 1192 (4.22 tail end)");
failures += check(ENGINE_TIMELINE_STOPPED == 0, "abi: ENGINE_TIMELINE_STOPPED == 0");
failures += check(ENGINE_TIMELINE_PLAYING == 1, "abi: ENGINE_TIMELINE_PLAYING == 1");
failures += check(ENGINE_TIMELINE_PAUSED == 2, "abi: ENGINE_TIMELINE_PAUSED == 2");
// Playback round-trip: play -> advance -> pause/seek/stop + rate/loop.
{
EngineTimeline* tl = api->timeline_create();
failures += check(tl != nullptr, "abi: playback timeline_create");
uint32_t track = api->timeline_track_add(tl, ENGINE_TIMELINE_TRACK_TRANSFORM);
failures += check(track != 0, "abi: playback track_add");
EngineTimelineClip clip{};
clip.start = 0.0F;
clip.duration = 4.0F;
clip.source_start = 0.0F;
uint32_t cid = api->timeline_clip_add(tl, track, &clip);
failures += check(cid != 0, "abi: playback clip_add");
EngineTimelinePlayer* pl = api->timeline_player_create(tl);
failures += check(pl != nullptr, "abi: timeline_player_create");
failures += check(api->timeline_player_create(nullptr) == nullptr,
"abi: timeline_player_create null -> NULL");
int32_t state = -1;
float time = -1.0F;
failures += check(api->timeline_player_state(pl, &state) == 0 && state == ENGINE_TIMELINE_STOPPED,
"abi: player initial STOPPED");
failures += check(api->timeline_player_play(pl) == 0, "abi: player play");
failures += check(api->timeline_player_state(pl, &state) == 0 && state == ENGINE_TIMELINE_PLAYING,
"abi: player PLAYING after play");
failures += check(api->timeline_player_advance(pl, 1.5F) == 0, "abi: player advance");
failures += check(api->timeline_player_time(pl, &time) == 0 && time == 1.5F,
"abi: player time 1.5 after advance");
failures += check(api->timeline_player_pause(pl) == 0, "abi: player pause");
failures += check(api->timeline_player_state(pl, &state) == 0 && state == ENGINE_TIMELINE_PAUSED,
"abi: player PAUSED after pause");
failures += check(api->timeline_player_advance(pl, 1.0F) == 0, "abi: paused advance no-op");
failures += check(api->timeline_player_time(pl, &time) == 0 && time == 1.5F,
"abi: paused time frozen");
failures += check(api->timeline_player_seek(pl, 3.0F) == 0, "abi: player seek");
failures += check(api->timeline_player_time(pl, &time) == 0 && time == 3.0F,
"abi: player time 3 after seek");
failures += check(api->timeline_player_set_rate(pl, 2.0F) == 0, "abi: player set_rate 2");
failures += check(api->timeline_player_set_rate(pl, 0.0F) != 0, "abi: player set_rate 0 rejects");
failures += check(api->timeline_player_set_looping(pl, 1) == 0, "abi: player set_looping on");
int32_t looping = 0;
failures += check(api->timeline_player_looping(pl, &looping) == 0 && looping == 1,
"abi: player looping reads 1");
failures += check(api->timeline_player_play(pl) == 0, "abi: player resume");
failures += check(api->timeline_player_advance(pl, 1.0F) == 0, "abi: loop advance wraps");
failures += check(api->timeline_player_time(pl, &time) == 0 && time == 1.0F,
"abi: loop wrap 3+2 -> 1");
failures += check(api->timeline_player_state(pl, &state) == 0 && state == ENGINE_TIMELINE_PLAYING,
"abi: loop stays PLAYING");
failures += check(api->timeline_player_set_looping(pl, 0) == 0, "abi: player set_looping off");
failures += check(api->timeline_player_seek(pl, 3.5F) == 0, "abi: player seek 3.5");
failures += check(api->timeline_player_advance(pl, 1.0F) == 0, "abi: non-loop advance pins end");
failures += check(api->timeline_player_time(pl, &time) == 0 && time == 4.0F,
"abi: non-loop pins at duration");
failures += check(api->timeline_player_state(pl, &state) == 0 && state == ENGINE_TIMELINE_STOPPED,
"abi: non-loop auto-stops at end");
failures += check(api->timeline_player_play(pl) == 0, "abi: play at end restarts");
failures += check(api->timeline_player_time(pl, &time) == 0 && time == 0.0F,
"abi: restart at 0");
failures += check(api->timeline_player_stop(pl) == 0, "abi: player stop");
failures += check(api->timeline_player_state(pl, &state) == 0 && state == ENGINE_TIMELINE_STOPPED,
"abi: player STOPPED after stop");
failures += check(api->timeline_player_time(pl, &time) == 0 && time == 0.0F,
"abi: player time 0 after stop");
failures += check(api->timeline_player_advance(pl, -1.0F) != 0, "abi: advance negative rejects");
failures += check(api->timeline_player_seek(nullptr, 0.0F) != 0, "abi: seek null rejects");
api->timeline_player_destroy(nullptr);
api->timeline_player_destroy(pl);
api->timeline_destroy(tl);
}
failures += check(sizeof(EngineTimelineActiveClip) == 16, "abi: EngineTimelineActiveClip 16B");
failures += check(offsetof(EngineTimelineActiveClip, clip) == 4,
"abi: EngineTimelineActiveClip.clip @ 4");
failures += check(offsetof(EngineTimelineActiveClip, local) == 8,
"abi: EngineTimelineActiveClip.local @ 8");
failures += check(offsetof(EngineTimelineActiveClip, weight) == 12,
"abi: EngineTimelineActiveClip.weight @ 12");
failures += check(sizeof(EngineTimelineClip) == 32, "abi: EngineTimelineClip 32B");
failures += check(offsetof(EngineTimelineClip, source_start) == 8,
"abi: EngineTimelineClip.source_start @ 8");
failures += check(offsetof(EngineTimelineClip, target_entity) == 16,
"abi: EngineTimelineClip.target_entity @ 16");
failures += check(offsetof(EngineTimelineClip, asset_id) == 24,
"abi: EngineTimelineClip.asset_id @ 24");
return failures;
}
auto main() -> int {
int failures = 0;
@@ -249,7 +345,7 @@ auto main() -> int {
failures += check(offsetof(EcsChunkView, world) == 8, "abi: EcsChunkView.world @ 8");
failures += check(offsetof(EcsChunkView, entities) == 16, "abi: EcsChunkView.entities @ 16");
failures += check(offsetof(EcsChunkView, user_data) == 40, "abi: EcsChunkView.user_data @ 40");
failures += check(sizeof(EngineApiTable) == 1008, "abi: EngineApiTable 1008B (ABI 4.19 renderer_attach_ui tail)");
failures += check(sizeof(EngineApiTable) == 1200, "abi: EngineApiTable 1200B (ABI 4.22 playback tail)");
failures += check(sizeof(EngineInputState) == 1668, "abi: EngineInputState 1668B");
failures += check(offsetof(EngineInputState, mouse_x) == 1536, "abi: EngineInputState.mouse_x @ 1536");
failures += check(offsetof(EngineInputState, mouse_buttons) == 1552, "abi: EngineInputState.mouse_buttons @ 1552");
@@ -419,6 +515,45 @@ auto main() -> int {
}
failures += check(offsetof(EngineApiTable, renderer_attach_ui) == 1000,
"abi: renderer_attach_ui @ 1000 (4.19 tail end)");
// P5-07 sequencer data-model tail (ABI 4.20): 6 appended entries after
// the 4.19 HUD-composite tail (renderer_attach_ui @ 1000).
failures += check(api->timeline_create != nullptr, "abi: timeline_create set");
failures += check(api->timeline_destroy != nullptr, "abi: timeline_destroy set");
failures += check(api->timeline_track_add != nullptr, "abi: timeline_track_add set");
failures += check(api->timeline_track_remove != nullptr, "abi: timeline_track_remove set");
failures += check(api->timeline_clip_add != nullptr, "abi: timeline_clip_add set");
failures += check(api->timeline_clip_remove != nullptr, "abi: timeline_clip_remove set");
failures += check(offsetof(EngineApiTable, timeline_create) == 1008,
"abi: timeline_create @ 1008 (4.20 tail)");
failures += check(offsetof(EngineApiTable, timeline_clip_remove) == 1048,
"abi: timeline_clip_remove @ 1048 (4.20 tail end)");
// P5-07 sequencer sequencing/timing tail (ABI 4.21): 5 appended entries
// after the 4.20 data-model tail (timeline_clip_remove @ 1048).
failures += check(api->timeline_duration != nullptr, "abi: timeline_duration set");
failures += check(api->timeline_clip_get != nullptr, "abi: timeline_clip_get set");
failures += check(api->timeline_sort != nullptr, "abi: timeline_sort set");
failures += check(api->timeline_trim != nullptr, "abi: timeline_trim set");
failures += check(api->timeline_evaluate_active != nullptr, "abi: timeline_evaluate_active set");
failures += check(offsetof(EngineApiTable, timeline_duration) == 1056,
"abi: timeline_duration @ 1056 (4.21 tail)");
failures += check(offsetof(EngineApiTable, timeline_evaluate_active) == 1088,
"abi: timeline_evaluate_active @ 1088 (4.21 tail end)");
// P5-07 sequencer playback-control tail (ABI 4.22): 13 appended entries
// after the 4.21 sequencing tail (timeline_evaluate_active @ 1088).
failures += check(api->timeline_player_create != nullptr, "abi: timeline_player_create set");
failures += check(api->timeline_player_destroy != nullptr, "abi: timeline_player_destroy set");
failures += check(api->timeline_player_play != nullptr, "abi: timeline_player_play set");
failures += check(api->timeline_player_pause != nullptr, "abi: timeline_player_pause set");
failures += check(api->timeline_player_stop != nullptr, "abi: timeline_player_stop set");
failures += check(api->timeline_player_seek != nullptr, "abi: timeline_player_seek set");
failures += check(api->timeline_player_advance != nullptr, "abi: timeline_player_advance set");
failures += check(api->timeline_player_state != nullptr, "abi: timeline_player_state set");
failures += check(api->timeline_player_time != nullptr, "abi: timeline_player_time set");
failures += check(api->timeline_player_set_rate != nullptr, "abi: timeline_player_set_rate set");
failures += check(api->timeline_player_rate != nullptr, "abi: timeline_player_rate set");
failures += check(api->timeline_player_set_looping != nullptr, "abi: timeline_player_set_looping set");
failures += check(api->timeline_player_looping != nullptr, "abi: timeline_player_looping set");
failures += CheckTimelineTail(api);
// P2-05 3D collision-event mirror: 32-byte record matching the 2D path.
failures += check(sizeof(EngineCollisionEvent) == 32, "abi: EngineCollisionEvent 32B");
failures += check(offsetof(EngineCollisionEvent, point_x) == 16,
+83 -1
View File
@@ -468,7 +468,8 @@ internal static class Program
or "int32_t*" or "char*"
or "EngineLogSinkCallback"
or "EngineAudioWorld*" or "const EngineAudioWorld*"
or "double" or "double*" or "EngineUi*" or "const EngineUi*";
or "double" or "double*" or "EngineUi*" or "const EngineUi*"
or "EngineTimeline*" or "const EngineTimelineClip*" or "const EngineTimeline*" or "EngineTimelineClip*" or "EngineTimelineActiveClip*" or "EngineTimelinePlayer*" or "const EngineTimelinePlayer*";
if (!ok)
{
throw new InvalidOperationException($"{header}:{line}: unsupported ABI type '{ctype}' (extend the generator's type map deliberately).");
@@ -575,6 +576,13 @@ internal static class Program
"double*" => "double*",
"EngineUi*" => "EngineUi*",
"const EngineUi*" => "EngineUi*",
"EngineTimeline*" => "EngineTimeline*",
"const EngineTimelineClip*" => "TimelineClip*",
"const EngineTimeline*" => "EngineTimeline*",
"EngineTimelineClip*" => "TimelineClip*",
"EngineTimelineActiveClip*" => "TimelineActiveClip*",
"EngineTimelinePlayer*" => "EngineTimelinePlayer*",
"const EngineTimelinePlayer*" => "EngineTimelinePlayer*",
_ => throw new InvalidOperationException($"no C# mapping for '{ctype}'"),
};
@@ -978,9 +986,69 @@ internal static class Program
sb.AppendLine();
}
private static void EmitTimelineTypes(StringBuilder sb, List<StructDef> structs)
{
// P5-07 sequencer mirrors (ABI 4.20): EngineTimelineClip (32B).
// Field order must match engine_abi.h exactly; the loader pins
// sizes at startup.
var clip = structs.Single(s => s.Name == "EngineTimelineClip");
string[] wantNames = ["start", "duration", "source_start", "reserved", "target_entity", "asset_id"];
string[] wantTypes = ["float", "float", "float", "uint32_t", "uint64_t", "uint64_t"];
if (clip.Fields.Count != wantNames.Length)
{
throw new InvalidOperationException($"EngineTimelineClip must have {wantNames.Length} fields (got {clip.Fields.Count}).");
}
for (var i = 0; i < wantNames.Length; i++)
{
if (clip.Fields[i].Name != wantNames[i] || clip.Fields[i].CType != wantTypes[i] || clip.Fields[i].ArrayLen != 0)
{
throw new InvalidOperationException($"EngineTimelineClip field {i} must be '{wantTypes[i]} {wantNames[i]}', got '{clip.Fields[i].CType} {clip.Fields[i].Name}'.");
}
}
sb.AppendLine(" /// <summary>Mirror of EngineTimelineClip: clip placement + payload binding, 32 bytes.</summary>");
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 4, Size = 32)]");
sb.AppendLine(" public struct TimelineClip");
sb.AppendLine(" {");
sb.AppendLine(" public float Start;");
sb.AppendLine(" public float Duration;");
sb.AppendLine(" public float SourceStart;");
sb.AppendLine(" public uint Reserved;");
sb.AppendLine(" public ulong TargetEntity;");
sb.AppendLine(" public ulong AssetId;");
sb.AppendLine(" }");
sb.AppendLine();
// P5-07 sequencing mirror (ABI 4.21): EngineTimelineActiveClip
// (16B: track + clip handles, local time, blend weight).
var active = structs.Single(s => s.Name == "EngineTimelineActiveClip");
string[] wantActiveNames = ["track", "clip", "local", "weight"];
string[] wantActiveTypes = ["uint32_t", "uint32_t", "float", "float"];
if (active.Fields.Count != wantActiveNames.Length)
{
throw new InvalidOperationException($"EngineTimelineActiveClip must have {wantActiveNames.Length} fields (got {active.Fields.Count}).");
}
for (var i = 0; i < wantActiveNames.Length; i++)
{
if (active.Fields[i].Name != wantActiveNames[i] || active.Fields[i].CType != wantActiveTypes[i] || active.Fields[i].ArrayLen != 0)
{
throw new InvalidOperationException($"EngineTimelineActiveClip field {i} must be '{wantActiveTypes[i]} {wantActiveNames[i]}', got '{active.Fields[i].CType} {active.Fields[i].Name}'.");
}
}
sb.AppendLine(" /// <summary>Mirror of EngineTimelineActiveClip: winning clip per track at time t, 16 bytes.</summary>");
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 4, Size = 16)]");
sb.AppendLine(" public struct TimelineActiveClip");
sb.AppendLine(" {");
sb.AppendLine(" public uint Track;");
sb.AppendLine(" public uint Clip;");
sb.AppendLine(" public float Local;");
sb.AppendLine(" public float Weight;");
sb.AppendLine(" }");
sb.AppendLine();
}
private static void EmitNavTypes(StringBuilder sb, List<StructDef> structs)
{
EmitCharacterTypes(sb, structs);
EmitTimelineTypes(sb, structs);
// Navigation mirrors (P6-03): EngineNavBox (24B), EngineNavBakeConfig
// (48B), EngineNavStats (8B), EngineNavAgent (64B). Field order must
// match engine_abi.h exactly; the loader pins sizes at startup.
@@ -1390,6 +1458,20 @@ internal static class Program
sb.AppendLine(" public void* Handle;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>Opaque sequencer timeline (owns tracks + clips, P5-07).</summary>");
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 8)]");
sb.AppendLine(" public unsafe struct EngineTimeline");
sb.AppendLine(" {");
sb.AppendLine(" public void* Handle;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>Opaque sequencer player (binds one timeline, owns the transport cursor, P5-07).</summary>");
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 8)]");
sb.AppendLine(" public unsafe struct EngineTimelinePlayer");
sb.AppendLine(" {");
sb.AppendLine(" public void* Handle;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>Opaque character handle (owns one engine::physics::CharacterBody, borrows the caller's PhysicsWorld).</summary>");
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 8)]");
sb.AppendLine(" public unsafe struct EngineCharacter");