@series-inc/rundot-kinetix 0.0.0-bootstrap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +77 -0
  2. package/authoring.d.ts +30 -0
  3. package/index.d.ts +221 -0
  4. package/native/component_runtime.cpp +341 -0
  5. package/native/component_runtime.hpp +112 -0
  6. package/native/deterministic_math.cpp +594 -0
  7. package/native/deterministic_math.hpp +21 -0
  8. package/native/kinetix-f64-native-profile.cpp +406 -0
  9. package/native/kinetix-f64-native-profile.hpp +5 -0
  10. package/native/kinetix-fixed1000-native-profile.cpp +777 -0
  11. package/native/kinetix-fixed1000-native-profile.hpp +58 -0
  12. package/native/kinetix-fixed1000-physics.cpp +887 -0
  13. package/native/kinetix-fixed1000-physics.hpp +28 -0
  14. package/native/kinetix-installed-f64-renderer.cpp +344 -0
  15. package/native/kinetix-installed-f64-renderer.hpp +35 -0
  16. package/native/kinetix-installed-f64-runtime.cpp +1085 -0
  17. package/native/kinetix-installed-f64-runtime.hpp +141 -0
  18. package/native/kinetix-installed-fixed1000-data.hpp +77 -0
  19. package/native/kinetix-native-main.cpp +37 -0
  20. package/native/kinetix-native-runtime.cpp +20 -0
  21. package/native/kinetix-native-runtime.hpp +25 -0
  22. package/native/kinetix-render-projection.cpp +20 -0
  23. package/native/kinetix-render-projection.hpp +14 -0
  24. package/package.json +65 -0
  25. package/runtime.d.ts +76 -0
  26. package/scripts/build-native.mjs +67 -0
  27. package/scripts/emit-product-digests.mjs +33 -0
  28. package/scripts/preflight.mjs +76 -0
  29. package/src/index.mjs +57 -0
  30. package/src/kinetix-authoring.mjs +69 -0
  31. package/src/kinetix-baker.mjs +587 -0
  32. package/src/kinetix-deterministic-math.mjs +1044 -0
  33. package/src/kinetix-fixed1000-runtime-adapter.mjs +33 -0
  34. package/src/kinetix-fixed1000-runtime.mjs +954 -0
  35. package/src/kinetix-installed-f64-reference.mjs +157 -0
  36. package/src/kinetix-installed-f64-render-frames.mjs +53 -0
  37. package/src/kinetix-installed-f64-renderer.mjs +240 -0
  38. package/src/kinetix-installed-f64-runtime-adapter.mjs +68 -0
  39. package/src/kinetix-installed-f64-runtime.mjs +607 -0
  40. package/src/kinetix-installed-mechanics.mjs +377 -0
  41. package/src/kinetix-installed-systems.mjs +181 -0
  42. package/src/kinetix-native-product.mjs +72 -0
  43. package/src/kinetix-product-contract.mjs +121 -0
  44. package/src/kinetix-project-compiler.mjs +1017 -0
  45. package/src/kinetix-render-projection.mjs +28 -0
  46. package/src/kinetix-runtime-adapter-utils.mjs +168 -0
  47. package/src/kinetix-runtime-contract.mjs +54 -0
  48. package/src/kinetix-session-config.mjs +170 -0
  49. package/src/kinetix-source-snapshot.mjs +24 -0
  50. package/src/kinetix-survival-runtime-adapter.mjs +305 -0
  51. package/src/kinetix-survival-runtime.generated.mjs +1 -0
  52. package/src/kinetix-world-kernel.mjs +580 -0
  53. package/src/kinetix-world-runtime.mjs +171 -0
  54. package/src/runtime.mjs +14 -0
  55. package/src/shared/f64-bits.mjs +14 -0
  56. package/src/shared/kinetix-envelope-v1.mjs +589 -0
  57. package/src/shared/render-records-v1.mjs +168 -0
  58. package/src/shared/sha256.mjs +73 -0
@@ -0,0 +1,1085 @@
1
+ // Deterministic-f64 component runtime: C++ transliteration of
2
+ // component-reference-runtime-f64.mjs. Every phase, iteration order,
3
+ // floating-point expression shape, and RNG draw mirrors the TS reference so
4
+ // canonical per-tick hashes are bit-identical.
5
+ //
6
+ // Numeric contract: IEEE-754 doubles; +,-,*,/ and sqrt only from hardware;
7
+ // every transcendental from rundot::detmath. JS Math.hypot maps to the shim's
8
+ // variadic left-fold: det_hypot2(det_hypot2(0.0, x), y). Compile with
9
+ // -ffp-contract=off.
10
+
11
+ #include "kinetix-installed-f64-runtime.hpp"
12
+
13
+ #include <array>
14
+ #include <bit>
15
+ #include <cmath>
16
+ #include <cstdint>
17
+ #include <cstring>
18
+ #include <stdexcept>
19
+
20
+ #include "component_runtime.hpp"
21
+ #include "deterministic_math.hpp"
22
+
23
+ namespace rundot::f64rt {
24
+ namespace {
25
+
26
+ constexpr double TWO_PI = 6.283185307179586;
27
+
28
+ [[noreturn]] void fail(const std::string& message) {
29
+ throw std::runtime_error("component runtime f64: " + message);
30
+ }
31
+
32
+ double detHypot(double x, double y) {
33
+ return rundot::detmath::det_hypot2(rundot::detmath::det_hypot2(0.0, x), y);
34
+ }
35
+
36
+ enum class Kind : std::uint8_t {
37
+ Transform2D,
38
+ TwinStickInput,
39
+ AxisDriveMover,
40
+ HeadingFromVectors,
41
+ SpawnShield,
42
+ WanderMover,
43
+ SeekMover,
44
+ Telegraph,
45
+ ArenaBounds,
46
+ AutoFireWeapon,
47
+ LinearMover,
48
+ Lifetime,
49
+ Health,
50
+ Emitter,
51
+ CollisionCircle,
52
+ ScoreValue,
53
+ ResourceCounter,
54
+ DecayingMultiplier,
55
+ Count,
56
+ };
57
+
58
+ constexpr std::size_t kKindCount = static_cast<std::size_t>(Kind::Count);
59
+
60
+ const char* const kKindNames[kKindCount] = {
61
+ "Transform2D",
62
+ "TwinStickInput",
63
+ "AxisDriveMover",
64
+ "HeadingFromVectors",
65
+ "SpawnShield",
66
+ "WanderMover",
67
+ "SeekMover",
68
+ "Telegraph",
69
+ "ArenaBounds",
70
+ "AutoFireWeapon",
71
+ "LinearMover",
72
+ "Lifetime",
73
+ "Health",
74
+ "Emitter",
75
+ "CollisionCircle",
76
+ "ScoreValue",
77
+ "ResourceCounter",
78
+ "DecayingMultiplier",
79
+ };
80
+
81
+ Kind kindFromType(const std::string& type) {
82
+ for (std::size_t index = 0; index < kKindCount; ++index) {
83
+ if (type == kKindNames[index]) return static_cast<Kind>(index);
84
+ }
85
+ fail("unknown component type " + type);
86
+ }
87
+
88
+ enum class BoundsMode : std::uint8_t { Clamp, Cull, ReflectNegate, ReflectAbs };
89
+
90
+ struct Component {
91
+ Kind kind = Kind::Transform2D;
92
+ // Transform2D
93
+ Vec2 position{};
94
+ Vec2 velocity{};
95
+ double heading = 0.0;
96
+ // TwinStickInput
97
+ std::string moveField;
98
+ std::string aimField;
99
+ std::string fireField;
100
+ std::int64_t slot = 0;
101
+ Vec2 move{};
102
+ Vec2 aim{};
103
+ bool fire = false;
104
+ // AxisDriveMover
105
+ std::string axisField;
106
+ double maxSpeedPxPerSec = 0.0;
107
+ // SpawnShield (seconds + remaining); Telegraph reuses remaining
108
+ double seconds = 0.0;
109
+ double remaining = 0.0;
110
+ // WanderMover (Emitter reuses speedPx + rngStream)
111
+ double speedPx = 0.0;
112
+ double rerollSeconds = 0.0;
113
+ double sigmaRad = 0.0;
114
+ std::string rngStream;
115
+ double rerollTimer = 0.0;
116
+ // SeekMover
117
+ double baseSpeedPx = 0.0;
118
+ double topSpeedPx = 0.0;
119
+ double accelStartSec = 0.0;
120
+ double accelEndSec = 0.0;
121
+ double aliveSeconds = 0.0;
122
+ // ArenaBounds
123
+ BoundsMode mode = BoundsMode::Clamp;
124
+ bool useRadius = false;
125
+ // AutoFireWeapon
126
+ double fireIntervalSec = 0.0;
127
+ double speedPxPerSec = 0.0;
128
+ double spreadRad = 0.0;
129
+ std::string bulletTemplate;
130
+ double cooldown = 0.0;
131
+ std::uint64_t nextBulletId = 1;
132
+ // Lifetime
133
+ double maxSeconds = 0.0;
134
+ double age = 0.0;
135
+ // Health
136
+ std::int64_t hp = 0;
137
+ // Emitter
138
+ std::string trigger;
139
+ std::string emitTemplate;
140
+ std::int64_t count = 0;
141
+ double extraOffsetPx = 0.0;
142
+ std::string pattern;
143
+ // CollisionCircle
144
+ double radius = 0.0;
145
+ std::string layer;
146
+ // ScoreValue
147
+ std::int64_t basePoints = 0;
148
+ // ResourceCounter
149
+ std::int64_t score = 0;
150
+ std::int64_t deaths = 0;
151
+ // DecayingMultiplier
152
+ double increment = 0.0;
153
+ double cap = 0.0;
154
+ double decayWindow = 0.0;
155
+ double value = 1.0;
156
+ };
157
+
158
+ const PropertyValue& prop(const ComponentDef& def, const char* name) {
159
+ const auto found = def.properties.find(name);
160
+ if (found == def.properties.end()) fail("missing property " + std::string(name) + " on " + def.type);
161
+ return found->second;
162
+ }
163
+
164
+ double propF64(const ComponentDef& def, const char* name) {
165
+ const PropertyValue& value = prop(def, name);
166
+ if (value.kind != PropertyValue::Kind::F64) fail("property " + std::string(name) + " on " + def.type + " is not f64");
167
+ return value.f64;
168
+ }
169
+
170
+ Vec2 propVec2(const ComponentDef& def, const char* name) {
171
+ const PropertyValue& value = prop(def, name);
172
+ if (value.kind != PropertyValue::Kind::Vec2) fail("property " + std::string(name) + " on " + def.type + " is not vec2");
173
+ return value.vec2;
174
+ }
175
+
176
+ const std::string& propStr(const ComponentDef& def, const char* name) {
177
+ const PropertyValue& value = prop(def, name);
178
+ if (value.kind != PropertyValue::Kind::String) fail("property " + std::string(name) + " on " + def.type + " is not string");
179
+ return value.str;
180
+ }
181
+
182
+ std::int64_t propInt(const ComponentDef& def, const char* name) {
183
+ const PropertyValue& value = prop(def, name);
184
+ if (value.kind != PropertyValue::Kind::Int) fail("property " + std::string(name) + " on " + def.type + " is not int");
185
+ return value.i64;
186
+ }
187
+
188
+ bool propBool(const ComponentDef& def, const char* name) {
189
+ const PropertyValue& value = prop(def, name);
190
+ if (value.kind != PropertyValue::Kind::Bool) fail("property " + std::string(name) + " on " + def.type + " is not bool");
191
+ return value.boolean;
192
+ }
193
+
194
+ BoundsMode boundsModeFromString(const std::string& mode) {
195
+ if (mode == "clamp") return BoundsMode::Clamp;
196
+ if (mode == "cull") return BoundsMode::Cull;
197
+ if (mode == "reflect-negate") return BoundsMode::ReflectNegate;
198
+ if (mode == "reflect-abs") return BoundsMode::ReflectAbs;
199
+ fail("unknown bounds mode " + mode);
200
+ }
201
+
202
+ Component instantiateComponent(const ComponentDef& def) {
203
+ Component component;
204
+ component.kind = kindFromType(def.type);
205
+ switch (component.kind) {
206
+ case Kind::Transform2D:
207
+ component.position = propVec2(def, "position");
208
+ component.velocity = propVec2(def, "velocity");
209
+ component.heading = propF64(def, "heading");
210
+ break;
211
+ case Kind::TwinStickInput:
212
+ component.moveField = propStr(def, "moveField");
213
+ component.aimField = propStr(def, "aimField");
214
+ component.fireField = propStr(def, "fireField");
215
+ component.slot = propInt(def, "slot");
216
+ break;
217
+ case Kind::AxisDriveMover:
218
+ component.axisField = propStr(def, "axisField");
219
+ component.maxSpeedPxPerSec = propF64(def, "maxSpeedPxPerSec");
220
+ break;
221
+ case Kind::HeadingFromVectors:
222
+ component.aimField = propStr(def, "aimField");
223
+ component.moveField = propStr(def, "moveField");
224
+ break;
225
+ case Kind::SpawnShield:
226
+ component.seconds = propF64(def, "seconds");
227
+ component.remaining = component.seconds;
228
+ break;
229
+ case Kind::WanderMover:
230
+ component.speedPx = propF64(def, "speedPx");
231
+ component.rerollSeconds = propF64(def, "rerollSeconds");
232
+ component.sigmaRad = propF64(def, "sigmaRad");
233
+ component.rngStream = propStr(def, "rngStream");
234
+ component.rerollTimer = component.rerollSeconds;
235
+ break;
236
+ case Kind::SeekMover:
237
+ component.baseSpeedPx = propF64(def, "baseSpeedPx");
238
+ component.topSpeedPx = propF64(def, "topSpeedPx");
239
+ component.accelStartSec = propF64(def, "accelStartSec");
240
+ component.accelEndSec = propF64(def, "accelEndSec");
241
+ break;
242
+ case Kind::Telegraph:
243
+ component.remaining = propF64(def, "seconds");
244
+ break;
245
+ case Kind::ArenaBounds:
246
+ component.mode = boundsModeFromString(propStr(def, "mode"));
247
+ component.useRadius = propBool(def, "useRadius");
248
+ break;
249
+ case Kind::AutoFireWeapon:
250
+ component.fireIntervalSec = propF64(def, "fireIntervalSec");
251
+ component.speedPxPerSec = propF64(def, "speedPxPerSec");
252
+ component.spreadRad = propF64(def, "spreadRad");
253
+ component.bulletTemplate = propStr(def, "bulletTemplate");
254
+ component.aimField = propStr(def, "aimField");
255
+ component.fireField = propStr(def, "fireField");
256
+ component.slot = propInt(def, "slot");
257
+ break;
258
+ case Kind::LinearMover:
259
+ break;
260
+ case Kind::Lifetime:
261
+ component.maxSeconds = propF64(def, "maxSeconds");
262
+ break;
263
+ case Kind::Health:
264
+ component.hp = propInt(def, "hp");
265
+ break;
266
+ case Kind::Emitter:
267
+ component.trigger = propStr(def, "trigger");
268
+ component.emitTemplate = propStr(def, "template");
269
+ component.count = propInt(def, "count");
270
+ component.speedPx = propF64(def, "speedPx");
271
+ component.extraOffsetPx = propF64(def, "extraOffsetPx");
272
+ component.pattern = propStr(def, "pattern");
273
+ component.rngStream = propStr(def, "rngStream");
274
+ break;
275
+ case Kind::CollisionCircle:
276
+ component.radius = propF64(def, "radius");
277
+ component.layer = propStr(def, "layer");
278
+ break;
279
+ case Kind::ScoreValue:
280
+ component.basePoints = propInt(def, "basePoints");
281
+ break;
282
+ case Kind::ResourceCounter:
283
+ component.score = propInt(def, "score");
284
+ component.deaths = 0;
285
+ break;
286
+ case Kind::DecayingMultiplier:
287
+ component.increment = propF64(def, "increment");
288
+ component.cap = propF64(def, "cap");
289
+ component.decayWindow = propF64(def, "decayWindow");
290
+ break;
291
+ default:
292
+ fail("unhandled component kind");
293
+ }
294
+ return component;
295
+ }
296
+
297
+ struct Entity {
298
+ std::string ref;
299
+ std::uint64_t spawnId = 0;
300
+ std::vector<Component> components;
301
+ std::array<int, kKindCount> byKind{};
302
+ std::map<std::string, Vec2> vecFields;
303
+ std::map<std::string, bool> boolFields;
304
+ bool alive = true;
305
+ bool hasSpawnPosition = false;
306
+ Vec2 spawnPosition{};
307
+ };
308
+
309
+ Component* findComponent(Entity& entity, Kind kind) {
310
+ const int index = entity.byKind[static_cast<std::size_t>(kind)];
311
+ return index < 0 ? nullptr : &entity.components[static_cast<std::size_t>(index)];
312
+ }
313
+
314
+ Entity makeEntity(const std::string& ref, std::uint64_t spawnId, const std::vector<ComponentDef>& defs) {
315
+ Entity entity;
316
+ entity.ref = ref;
317
+ entity.spawnId = spawnId;
318
+ entity.byKind.fill(-1);
319
+ entity.components.reserve(defs.size());
320
+ for (const ComponentDef& def : defs) {
321
+ Component component = instantiateComponent(def);
322
+ const std::size_t kindIndex = static_cast<std::size_t>(component.kind);
323
+ if (entity.byKind[kindIndex] >= 0) fail("duplicate component " + def.type + " on " + ref);
324
+ entity.byKind[kindIndex] = static_cast<int>(entity.components.size());
325
+ entity.components.push_back(std::move(component));
326
+ }
327
+ if (Component* transform = findComponent(entity, Kind::Transform2D)) {
328
+ entity.hasSpawnPosition = true;
329
+ entity.spawnPosition = transform->position;
330
+ }
331
+ return entity;
332
+ }
333
+
334
+ struct RngStreamRt {
335
+ std::string id;
336
+ std::uint32_t state = 0;
337
+ };
338
+
339
+ struct TemplateRt {
340
+ std::string id;
341
+ std::int64_t pool = 0;
342
+ Entity proto;
343
+ std::int64_t liveCount = 0;
344
+ };
345
+
346
+ struct Runtime {
347
+ const PackDef* pack = nullptr;
348
+ double dt = 1.0 / 60.0;
349
+ double width = 1280.0;
350
+ double height = 720.0;
351
+ std::int64_t tick = 0;
352
+ std::vector<RngStreamRt> rngStreams;
353
+ std::map<std::string, std::size_t> rngIndex;
354
+ std::vector<Entity> statics;
355
+ std::vector<Entity> pooled;
356
+ std::uint64_t nextSpawnId = 1;
357
+ std::vector<TemplateRt> templates;
358
+ std::map<std::string, std::size_t> templateIndex;
359
+ std::int64_t spawnsThisTick = 0;
360
+ std::size_t pooledCapacity = 0;
361
+ // Render-facing event log for the current tick (mirrors JS
362
+ // runtime.frameEvents). Not part of canonical state or hashing.
363
+ std::vector<FrameEventDef> frameEvents;
364
+ };
365
+
366
+ Entity& entityAt(Runtime& runtime, int handle) {
367
+ const int staticCount = static_cast<int>(runtime.statics.size());
368
+ return handle < staticCount ? runtime.statics[static_cast<std::size_t>(handle)]
369
+ : runtime.pooled[static_cast<std::size_t>(handle - staticCount)];
370
+ }
371
+
372
+ Runtime createRuntime(const PackDef& pack) {
373
+ Runtime runtime;
374
+ runtime.pack = &pack;
375
+ for (const RngStreamDef& stream : pack.rngStreams) {
376
+ runtime.rngIndex[stream.id] = runtime.rngStreams.size();
377
+ runtime.rngStreams.push_back({stream.id, stream.seed});
378
+ }
379
+ for (const EntityDef& entity : pack.entities) {
380
+ runtime.statics.push_back(makeEntity(entity.id, 0, entity.components));
381
+ }
382
+ for (const TemplateDef& templateDef : pack.templates) {
383
+ runtime.templateIndex[templateDef.id] = runtime.templates.size();
384
+ runtime.templates.push_back({templateDef.id, templateDef.pool, makeEntity(templateDef.id, 0, templateDef.components), 0});
385
+ }
386
+ // Alive pooled entities are bounded by the summed template pools; dead ones
387
+ // linger only until the end-of-tick filter, so per-tick growth is bounded by
388
+ // maxSpawnsPerTick. Reserving the worst case keeps element pointers stable
389
+ // across mid-tick spawns.
390
+ std::size_t totalPool = 0;
391
+ for (const TemplateDef& templateDef : pack.templates) totalPool += static_cast<std::size_t>(templateDef.pool);
392
+ runtime.pooledCapacity = totalPool + static_cast<std::size_t>(pack.maxSpawnsPerTick);
393
+ runtime.pooled.reserve(runtime.pooledCapacity);
394
+ return runtime;
395
+ }
396
+
397
+ // liveEntities() in the reference includes ALL static entities (even dead
398
+ // ones) plus alive pooled entities; only hashing filters statics by alive.
399
+ std::vector<int> liveEntities(const Runtime& runtime) {
400
+ std::vector<int> handles;
401
+ handles.reserve(runtime.statics.size() + runtime.pooled.size());
402
+ const int staticCount = static_cast<int>(runtime.statics.size());
403
+ for (int index = 0; index < staticCount; ++index) handles.push_back(index);
404
+ for (std::size_t index = 0; index < runtime.pooled.size(); ++index) {
405
+ if (runtime.pooled[index].alive) handles.push_back(staticCount + static_cast<int>(index));
406
+ }
407
+ return handles;
408
+ }
409
+
410
+ std::uint32_t mulberry32Next(std::uint32_t& state) {
411
+ state = state + 0x6d2b79f5u;
412
+ std::uint32_t t = state;
413
+ t = static_cast<std::uint32_t>(static_cast<std::uint64_t>(t ^ (t >> 15)) * (t | 1u));
414
+ t = t ^ (t + static_cast<std::uint32_t>(static_cast<std::uint64_t>(t ^ (t >> 7)) * (t | 61u)));
415
+ return t ^ (t >> 14);
416
+ }
417
+
418
+ double rngFloat(Runtime& runtime, const std::string& streamId) {
419
+ const auto found = runtime.rngIndex.find(streamId);
420
+ if (found == runtime.rngIndex.end()) fail("unknown rng stream " + streamId);
421
+ RngStreamRt& stream = runtime.rngStreams[found->second];
422
+ const std::uint32_t value = mulberry32Next(stream.state);
423
+ return static_cast<double>(value) / 4294967296.0;
424
+ }
425
+
426
+ double gaussian(Runtime& runtime, const std::string& streamId) {
427
+ const double raw = rngFloat(runtime, streamId);
428
+ const double u1 = raw > 1e-9 ? raw : 1e-9; // Math.max(raw, 1e-9); raw is never NaN
429
+ const double u2 = rngFloat(runtime, streamId);
430
+ return std::sqrt(-2.0 * rundot::detmath::det_log(u1)) * rundot::detmath::det_cos(TWO_PI * u2);
431
+ }
432
+
433
+ int spawnFromTemplate(Runtime& runtime, const std::string& templateId) {
434
+ const auto found = runtime.templateIndex.find(templateId);
435
+ if (found == runtime.templateIndex.end()) fail("unknown template " + templateId);
436
+ TemplateRt& templateRt = runtime.templates[found->second];
437
+ if (templateRt.liveCount >= templateRt.pool) return -1;
438
+ if (runtime.spawnsThisTick >= runtime.pack->maxSpawnsPerTick) return -1;
439
+ Entity entity = templateRt.proto;
440
+ entity.spawnId = runtime.nextSpawnId;
441
+ runtime.nextSpawnId += 1;
442
+ runtime.spawnsThisTick += 1;
443
+ templateRt.liveCount += 1;
444
+ runtime.pooled.push_back(std::move(entity));
445
+ return static_cast<int>(runtime.statics.size()) + static_cast<int>(runtime.pooled.size()) - 1;
446
+ }
447
+
448
+ void despawn(Runtime& runtime, Entity& entity) {
449
+ if (!entity.alive) return;
450
+ entity.alive = false;
451
+ const auto found = runtime.templateIndex.find(entity.ref);
452
+ if (found == runtime.templateIndex.end()) fail("despawn of non-pooled entity " + entity.ref);
453
+ runtime.templates[found->second].liveCount -= 1;
454
+ }
455
+
456
+ bool shipIsShielded(Entity& entity) {
457
+ Component* shield = findComponent(entity, Kind::SpawnShield);
458
+ return shield != nullptr && shield->remaining > 0;
459
+ }
460
+
461
+ bool entityTelegraphing(Entity& entity) {
462
+ Component* telegraph = findComponent(entity, Kind::Telegraph);
463
+ return telegraph != nullptr && telegraph->remaining > 0;
464
+ }
465
+
466
+ bool nearestShipPosition(Runtime& runtime, const Vec2& from, Vec2& out) {
467
+ bool found = false;
468
+ double bestDistSq = 0.0;
469
+ for (Entity& entity : runtime.statics) {
470
+ if (findComponent(entity, Kind::AxisDriveMover) == nullptr) continue;
471
+ Component* transform = findComponent(entity, Kind::Transform2D);
472
+ const double dx = transform->position.x - from.x;
473
+ const double dy = transform->position.y - from.y;
474
+ const double distSq = dx * dx + dy * dy;
475
+ if (!found || distSq < bestDistSq) {
476
+ found = true;
477
+ bestDistSq = distSq;
478
+ out = transform->position;
479
+ }
480
+ }
481
+ return found;
482
+ }
483
+
484
+ void applyBounds(Runtime& runtime, Entity& entity, Component& bounds) {
485
+ Component* transform = findComponent(entity, Kind::Transform2D);
486
+ Component* collision = findComponent(entity, Kind::CollisionCircle);
487
+ const double radius = bounds.useRadius && collision != nullptr ? collision->radius : 0.0;
488
+ Vec2& position = transform->position;
489
+ Vec2& velocity = transform->velocity;
490
+ if (bounds.mode == BoundsMode::Clamp) {
491
+ if (position.x < radius) position.x = radius;
492
+ if (position.y < radius) position.y = radius;
493
+ if (position.x > runtime.width - radius) position.x = runtime.width - radius;
494
+ if (position.y > runtime.height - radius) position.y = runtime.height - radius;
495
+ return;
496
+ }
497
+ if (bounds.mode == BoundsMode::Cull) {
498
+ if (position.x < 0 || position.x > runtime.width || position.y < 0 || position.y > runtime.height) {
499
+ despawn(runtime, entity);
500
+ }
501
+ return;
502
+ }
503
+ if (bounds.mode == BoundsMode::ReflectNegate) {
504
+ if (position.x < radius) { position.x = radius; velocity.x = -velocity.x; }
505
+ if (position.y < radius) { position.y = radius; velocity.y = -velocity.y; }
506
+ if (position.x > runtime.width - radius) { position.x = runtime.width - radius; velocity.x = -velocity.x; }
507
+ if (position.y > runtime.height - radius) { position.y = runtime.height - radius; velocity.y = -velocity.y; }
508
+ return;
509
+ }
510
+ // reflect-abs
511
+ if (position.x < radius) { position.x = radius; velocity.x = std::fabs(velocity.x); }
512
+ if (position.y < radius) { position.y = radius; velocity.y = std::fabs(velocity.y); }
513
+ if (position.x > runtime.width - radius) { position.x = runtime.width - radius; velocity.x = -std::fabs(velocity.x); }
514
+ if (position.y > runtime.height - radius) { position.y = runtime.height - radius; velocity.y = -std::fabs(velocity.y); }
515
+ }
516
+
517
+ // Installed projectile-family deterministic spread.
518
+ double spreadForBullet(double id, double spreadRad) {
519
+ const double seeded = rundot::detmath::det_sin(id * 12.9898 + 78.233) * 43758.5453;
520
+ const double unit = seeded - std::floor(seeded);
521
+ return (unit * 2 - 1) * spreadRad;
522
+ }
523
+
524
+ bool circleHit(const Vec2& a, double ra, const Vec2& b, double rb) {
525
+ const double dx = a.x - b.x;
526
+ const double dy = a.y - b.y;
527
+ const double r = ra + rb;
528
+ return dx * dx + dy * dy <= r * r;
529
+ }
530
+
531
+ struct DeathEvent {
532
+ bool enemyKilled = false; // false = ship-death
533
+ int handle = -1;
534
+ };
535
+
536
+ Vec2 getVecField(Entity& entity, const std::string& name) {
537
+ const auto found = entity.vecFields.find(name);
538
+ return found == entity.vecFields.end() ? Vec2{} : found->second;
539
+ }
540
+
541
+ bool getBoolField(Entity& entity, const std::string& name) {
542
+ const auto found = entity.boolFields.find(name);
543
+ return found != entity.boolFields.end() && found->second;
544
+ }
545
+
546
+ void runCollisionPass(Runtime& runtime, const std::vector<int>& live, const char* action, std::vector<DeathEvent>& events) {
547
+ for (const CollisionRuleDef& rule : runtime.pack->collisionRules) {
548
+ if (rule.action != action) continue;
549
+ std::vector<int> groupA;
550
+ std::vector<int> groupB;
551
+ for (const int handle : live) {
552
+ Entity& entity = entityAt(runtime, handle);
553
+ if (!entity.alive) continue;
554
+ Component* circle = findComponent(entity, Kind::CollisionCircle);
555
+ if (circle == nullptr) continue;
556
+ if (circle->layer == rule.layerA) groupA.push_back(handle);
557
+ if (circle->layer == rule.layerB) groupB.push_back(handle);
558
+ }
559
+ if (rule.action == "bullet-kill") {
560
+ for (const int bulletHandle : groupA) {
561
+ Entity& bullet = entityAt(runtime, bulletHandle);
562
+ if (!bullet.alive) continue;
563
+ Component* bulletCircle = findComponent(bullet, Kind::CollisionCircle);
564
+ Component* bulletTransform = findComponent(bullet, Kind::Transform2D);
565
+ for (const int enemyHandle : groupB) {
566
+ Entity& enemy = entityAt(runtime, enemyHandle);
567
+ if (!enemy.alive || entityTelegraphing(enemy)) continue;
568
+ Component* enemyCircle = findComponent(enemy, Kind::CollisionCircle);
569
+ Component* enemyTransform = findComponent(enemy, Kind::Transform2D);
570
+ if (!circleHit(bulletTransform->position, bulletCircle->radius, enemyTransform->position, enemyCircle->radius)) continue;
571
+ Component* health = findComponent(enemy, Kind::Health);
572
+ health->hp -= 1;
573
+ despawn(runtime, bullet);
574
+ if (health->hp <= 0) {
575
+ events.push_back({true, enemyHandle});
576
+ }
577
+ break;
578
+ }
579
+ }
580
+ } else if (rule.action == "ship-death") {
581
+ for (const int shipHandle : groupA) {
582
+ Entity& ship = entityAt(runtime, shipHandle);
583
+ if (shipIsShielded(ship)) continue;
584
+ Component* shipCircle = findComponent(ship, Kind::CollisionCircle);
585
+ Component* shipTransform = findComponent(ship, Kind::Transform2D);
586
+ for (const int enemyHandle : groupB) {
587
+ Entity& enemy = entityAt(runtime, enemyHandle);
588
+ if (!enemy.alive || entityTelegraphing(enemy)) continue;
589
+ Component* enemyCircle = findComponent(enemy, Kind::CollisionCircle);
590
+ Component* enemyTransform = findComponent(enemy, Kind::Transform2D);
591
+ if (!circleHit(shipTransform->position, shipCircle->radius, enemyTransform->position, enemyCircle->radius)) continue;
592
+ events.push_back({false, shipHandle});
593
+ break;
594
+ }
595
+ }
596
+ }
597
+ }
598
+ }
599
+
600
+ void handleDeathEvents(Runtime& runtime, const std::vector<DeathEvent>& events) {
601
+ Entity* game = nullptr;
602
+ for (Entity& entity : runtime.statics) {
603
+ if (findComponent(entity, Kind::ResourceCounter) != nullptr) {
604
+ game = &entity;
605
+ break;
606
+ }
607
+ }
608
+ for (const DeathEvent& event : events) {
609
+ Entity& target = entityAt(runtime, event.handle);
610
+ {
611
+ // Mirrors the JS push at the TOP of handleDeathEvents' per-event loop:
612
+ // position read before any respawn/despawn mutation.
613
+ Component* transform = findComponent(target, Kind::Transform2D);
614
+ runtime.frameEvents.push_back({event.enemyKilled ? "enemy-killed" : "ship-death", target.ref,
615
+ transform->position.x, transform->position.y});
616
+ }
617
+ if (event.enemyKilled) {
618
+ Component* emitter = findComponent(target, Kind::Emitter);
619
+ if (emitter != nullptr && emitter->trigger == "on-death") {
620
+ Component* transform = findComponent(target, Kind::Transform2D);
621
+ Component* circle = findComponent(target, Kind::CollisionCircle);
622
+ const double speed = detHypot(transform->velocity.x, transform->velocity.y);
623
+ const double baseAngle = speed > 1
624
+ ? rundot::detmath::det_atan2(transform->velocity.y, transform->velocity.x)
625
+ : rngFloat(runtime, emitter->rngStream) * TWO_PI;
626
+ const double phase = rngFloat(runtime, emitter->rngStream) * TWO_PI;
627
+ const auto templateFound = runtime.templateIndex.find(emitter->emitTemplate);
628
+ if (templateFound == runtime.templateIndex.end()) fail("unknown emitter template " + emitter->emitTemplate);
629
+ Entity& templateProto = runtime.templates[templateFound->second].proto;
630
+ Component* templateCircle = findComponent(templateProto, Kind::CollisionCircle);
631
+ const double childRadius = templateCircle != nullptr ? templateCircle->radius : 0.0;
632
+ const double spawnOffset = circle->radius + childRadius + emitter->extraOffsetPx;
633
+ // Snapshot spawn state used inside the loop: pooled growth may
634
+ // reallocate and invalidate transform/emitter pointers.
635
+ const Vec2 origin = transform->position;
636
+ const double emitSpeed = emitter->speedPx;
637
+ const std::string emitTemplate = emitter->emitTemplate;
638
+ const std::int64_t emitCount = emitter->count;
639
+ for (std::int64_t index = 0; index < emitCount; index += 1) {
640
+ const double angle = baseAngle + phase + (static_cast<double>(index) / static_cast<double>(emitCount)) * TWO_PI;
641
+ const double cos = rundot::detmath::det_cos(angle);
642
+ const double sin = rundot::detmath::det_sin(angle);
643
+ const int childHandle = spawnFromTemplate(runtime, emitTemplate);
644
+ if (childHandle >= 0) {
645
+ Entity& child = entityAt(runtime, childHandle);
646
+ Component* childTransform = findComponent(child, Kind::Transform2D);
647
+ childTransform->position.x = origin.x + cos * spawnOffset;
648
+ childTransform->position.y = origin.y + sin * spawnOffset;
649
+ childTransform->velocity.x = cos * emitSpeed;
650
+ childTransform->velocity.y = sin * emitSpeed;
651
+ // JS pushes 'spawned' inside spawnFromTemplate after setup ran;
652
+ // here setup is inlined at the call site, so push after it.
653
+ runtime.frameEvents.push_back({"spawned", emitTemplate, childTransform->position.x, childTransform->position.y});
654
+ }
655
+ }
656
+ }
657
+ Entity& entity = entityAt(runtime, event.handle);
658
+ Component* scoreValue = findComponent(entity, Kind::ScoreValue);
659
+ if (scoreValue != nullptr && game != nullptr) {
660
+ findComponent(*game, Kind::ResourceCounter)->score += scoreValue->basePoints;
661
+ }
662
+ if (entity.spawnId == 0) {
663
+ entity.alive = false;
664
+ } else {
665
+ despawn(runtime, entity);
666
+ }
667
+ } else {
668
+ if (game != nullptr) findComponent(*game, Kind::ResourceCounter)->deaths += 1;
669
+ Entity& entity = entityAt(runtime, event.handle);
670
+ Component* transform = findComponent(entity, Kind::Transform2D);
671
+ transform->position.x = entity.spawnPosition.x;
672
+ transform->position.y = entity.spawnPosition.y;
673
+ transform->velocity.x = 0;
674
+ transform->velocity.y = 0;
675
+ Component* shield = findComponent(entity, Kind::SpawnShield);
676
+ if (shield != nullptr) shield->remaining = shield->seconds;
677
+ }
678
+ }
679
+ }
680
+
681
+ void tickRuntime(Runtime& runtime, const std::vector<SlotInputDef>& inputs) {
682
+ const double dt = runtime.dt;
683
+ runtime.spawnsThisTick = 0;
684
+ runtime.frameEvents.clear();
685
+ runtime.pooled.reserve(runtime.pooledCapacity);
686
+ const std::vector<int> live = liveEntities(runtime);
687
+
688
+ // 1. inputs
689
+ for (const int handle : live) {
690
+ Entity& entity = entityAt(runtime, handle);
691
+ Component* twinStick = findComponent(entity, Kind::TwinStickInput);
692
+ if (twinStick == nullptr) continue;
693
+ SlotInputDef slotInput;
694
+ if (twinStick->slot >= 0 && static_cast<std::size_t>(twinStick->slot) < inputs.size()) {
695
+ slotInput = inputs[static_cast<std::size_t>(twinStick->slot)];
696
+ }
697
+ twinStick->move = slotInput.movement;
698
+ twinStick->aim = slotInput.aim;
699
+ twinStick->fire = slotInput.fire;
700
+ entity.vecFields[twinStick->moveField] = twinStick->move;
701
+ entity.vecFields[twinStick->aimField] = twinStick->aim;
702
+ entity.boolFields[twinStick->fireField] = twinStick->fire;
703
+ }
704
+
705
+ // 2. shield + telegraph countdowns
706
+ for (const int handle : live) {
707
+ Entity& entity = entityAt(runtime, handle);
708
+ Component* shield = findComponent(entity, Kind::SpawnShield);
709
+ if (shield != nullptr && shield->remaining > 0) {
710
+ const double next = shield->remaining - dt;
711
+ shield->remaining = next > 0 ? next : 0; // Math.max(0, next)
712
+ }
713
+ Component* telegraph = findComponent(entity, Kind::Telegraph);
714
+ if (telegraph != nullptr && telegraph->remaining > 0) {
715
+ const double next = telegraph->remaining - dt;
716
+ telegraph->remaining = next > 0 ? next : 0;
717
+ }
718
+ }
719
+
720
+ // 3. driven movement
721
+ for (const int handle : live) {
722
+ Entity& entity = entityAt(runtime, handle);
723
+ Component* drive = findComponent(entity, Kind::AxisDriveMover);
724
+ if (drive == nullptr) continue;
725
+ Component* transform = findComponent(entity, Kind::Transform2D);
726
+ const Vec2 movement = getVecField(entity, drive->axisField);
727
+ const double mag = detHypot(movement.x, movement.y);
728
+ if (mag > 0.0001) {
729
+ const double denom = mag > 1 ? mag : 1; // Math.max(mag, 1)
730
+ const double nx = movement.x / denom;
731
+ const double ny = movement.y / denom;
732
+ transform->position.x += nx * drive->maxSpeedPxPerSec * dt;
733
+ transform->position.y += ny * drive->maxSpeedPxPerSec * dt;
734
+ }
735
+ Component* bounds = findComponent(entity, Kind::ArenaBounds);
736
+ if (bounds != nullptr) applyBounds(runtime, entity, *bounds);
737
+ Component* headingComponent = findComponent(entity, Kind::HeadingFromVectors);
738
+ if (headingComponent != nullptr) {
739
+ const Vec2 aim = getVecField(entity, headingComponent->aimField);
740
+ const double aimMag = detHypot(aim.x, aim.y);
741
+ if (aimMag > 0.0001) {
742
+ transform->heading = rundot::detmath::det_atan2(aim.y, aim.x);
743
+ } else if (mag > 0.0001) {
744
+ transform->heading = rundot::detmath::det_atan2(movement.y, movement.x);
745
+ }
746
+ }
747
+ }
748
+
749
+ // 4. auto-fire (BulletSwarm.autoFire transcription)
750
+ for (const int handle : live) {
751
+ Entity& entity = entityAt(runtime, handle);
752
+ Component* weapon = findComponent(entity, Kind::AutoFireWeapon);
753
+ if (weapon == nullptr) continue;
754
+ const bool fire = getBoolField(entity, weapon->fireField);
755
+ if (!fire) {
756
+ weapon->cooldown = 0;
757
+ continue;
758
+ }
759
+ const Vec2 aim = getVecField(entity, weapon->aimField);
760
+ const double aimMag = detHypot(aim.x, aim.y);
761
+ if (aimMag < 0.0001) continue;
762
+ Component* transform = findComponent(entity, Kind::Transform2D);
763
+ const Vec2 origin = transform->position;
764
+ double cooldown = weapon->cooldown - dt;
765
+ int fired = 0;
766
+ while (cooldown <= 0) {
767
+ cooldown += weapon->fireIntervalSec;
768
+ const double nx = aim.x / aimMag;
769
+ const double ny = aim.y / aimMag;
770
+ const std::uint64_t bulletId = weapon->nextBulletId;
771
+ const double spread = spreadForBullet(static_cast<double>(bulletId), weapon->spreadRad);
772
+ const double cos = rundot::detmath::det_cos(spread);
773
+ const double sin = rundot::detmath::det_sin(spread);
774
+ const double sx = nx * cos - ny * sin;
775
+ const double sy = nx * sin + ny * cos;
776
+ const int bulletHandle = spawnFromTemplate(runtime, weapon->bulletTemplate);
777
+ if (bulletHandle < 0) break; // pool cap: break WITHOUT incrementing nextBulletId
778
+ Entity& bullet = entityAt(runtime, bulletHandle);
779
+ Component* bulletTransform = findComponent(bullet, Kind::Transform2D);
780
+ bulletTransform->position.x = origin.x;
781
+ bulletTransform->position.y = origin.y;
782
+ bulletTransform->velocity.x = sx * weapon->speedPxPerSec;
783
+ bulletTransform->velocity.y = sy * weapon->speedPxPerSec;
784
+ bulletTransform->heading = rundot::detmath::det_atan2(sy, sx);
785
+ // JS pushes 'spawned' inside spawnFromTemplate after setup ran; here
786
+ // setup is inlined at the call site, so push after it.
787
+ runtime.frameEvents.push_back({"spawned", weapon->bulletTemplate, bulletTransform->position.x, bulletTransform->position.y});
788
+ weapon->nextBulletId += 1;
789
+ fired += 1;
790
+ if (fired > 100) break;
791
+ }
792
+ weapon->cooldown = cooldown;
793
+ }
794
+
795
+ // refresh live to include this tick's spawns for integration
796
+ const std::vector<int> liveAfterFire = liveEntities(runtime);
797
+
798
+ // 5. linear movers integrate + bounds
799
+ for (const int handle : liveAfterFire) {
800
+ Entity& entity = entityAt(runtime, handle);
801
+ if (!entity.alive || findComponent(entity, Kind::LinearMover) == nullptr) continue;
802
+ Component* transform = findComponent(entity, Kind::Transform2D);
803
+ transform->position.x += transform->velocity.x * dt;
804
+ transform->position.y += transform->velocity.y * dt;
805
+ Component* lifetime = findComponent(entity, Kind::Lifetime);
806
+ if (lifetime != nullptr) lifetime->age += dt;
807
+ Component* bounds = findComponent(entity, Kind::ArenaBounds);
808
+ if (bounds != nullptr) applyBounds(runtime, entity, *bounds);
809
+ }
810
+
811
+ std::vector<DeathEvent> events;
812
+ // 6. pre-AI bullet collisions
813
+ runCollisionPass(runtime, liveAfterFire, "bullet-kill", events);
814
+ handleDeathEvents(runtime, events);
815
+ events.clear();
816
+
817
+ // 7. steering behaviors
818
+ for (const int handle : liveEntities(runtime)) {
819
+ Entity& entity = entityAt(runtime, handle);
820
+ if (!entity.alive || entityTelegraphing(entity)) continue;
821
+ Component* wander = findComponent(entity, Kind::WanderMover);
822
+ if (wander != nullptr) {
823
+ Component* transform = findComponent(entity, Kind::Transform2D);
824
+ wander->rerollTimer -= dt;
825
+ if (wander->rerollTimer <= 0) {
826
+ wander->rerollTimer += wander->rerollSeconds;
827
+ const double current = rundot::detmath::det_atan2(transform->velocity.y, transform->velocity.x);
828
+ const double nudged = current + gaussian(runtime, wander->rngStream) * wander->sigmaRad;
829
+ transform->velocity.x = rundot::detmath::det_cos(nudged) * wander->speedPx;
830
+ transform->velocity.y = rundot::detmath::det_sin(nudged) * wander->speedPx;
831
+ }
832
+ transform->position.x += transform->velocity.x * dt;
833
+ transform->position.y += transform->velocity.y * dt;
834
+ Component* bounds = findComponent(entity, Kind::ArenaBounds);
835
+ if (bounds != nullptr) applyBounds(runtime, entity, *bounds);
836
+ }
837
+ Component* seek = findComponent(entity, Kind::SeekMover);
838
+ if (seek != nullptr) {
839
+ Component* transform = findComponent(entity, Kind::Transform2D);
840
+ seek->aliveSeconds += dt;
841
+ const double t = seek->aliveSeconds;
842
+ double speed = seek->baseSpeedPx;
843
+ if (t > seek->accelStartSec) {
844
+ const double raw = (t - seek->accelStartSec) / (seek->accelEndSec - seek->accelStartSec);
845
+ const double lerp = raw < 1 ? raw : 1; // Math.min(1, raw)
846
+ speed = seek->baseSpeedPx + (seek->topSpeedPx - seek->baseSpeedPx) * lerp;
847
+ }
848
+ Vec2 target;
849
+ if (nearestShipPosition(runtime, transform->position, target)) {
850
+ const double dx = target.x - transform->position.x;
851
+ const double dy = target.y - transform->position.y;
852
+ const double mag = detHypot(dx, dy);
853
+ if (mag > 0.0001) {
854
+ transform->velocity.x = (dx / mag) * speed;
855
+ transform->velocity.y = (dy / mag) * speed;
856
+ }
857
+ }
858
+ transform->position.x += transform->velocity.x * dt;
859
+ transform->position.y += transform->velocity.y * dt;
860
+ Component* bounds = findComponent(entity, Kind::ArenaBounds);
861
+ if (bounds != nullptr) applyBounds(runtime, entity, *bounds);
862
+ }
863
+ }
864
+
865
+ // 8. post-AI collisions
866
+ const std::vector<int> liveAfterAi = liveEntities(runtime);
867
+ runCollisionPass(runtime, liveAfterAi, "bullet-kill", events);
868
+ runCollisionPass(runtime, liveAfterAi, "ship-death", events);
869
+ handleDeathEvents(runtime, events);
870
+ events.clear();
871
+
872
+ // 9. lifetimes
873
+ for (const int handle : liveEntities(runtime)) {
874
+ Entity& entity = entityAt(runtime, handle);
875
+ Component* lifetime = findComponent(entity, Kind::Lifetime);
876
+ if (lifetime != nullptr && lifetime->age >= lifetime->maxSeconds) despawn(runtime, entity);
877
+ }
878
+
879
+ std::vector<Entity> survivors;
880
+ survivors.reserve(runtime.pooled.size());
881
+ for (Entity& entity : runtime.pooled) {
882
+ if (entity.alive) survivors.push_back(std::move(entity));
883
+ }
884
+ runtime.pooled = std::move(survivors);
885
+ runtime.tick += 1;
886
+ }
887
+
888
+ void putBytes(std::vector<std::uint8_t>& buffer, const void* data, std::size_t size) {
889
+ const std::uint8_t* bytes = static_cast<const std::uint8_t*>(data);
890
+ buffer.insert(buffer.end(), bytes, bytes + size);
891
+ }
892
+
893
+ void putU32(std::vector<std::uint8_t>& buffer, std::uint32_t value) {
894
+ const std::uint8_t bytes[4] = {
895
+ static_cast<std::uint8_t>(value & 0xff),
896
+ static_cast<std::uint8_t>((value >> 8) & 0xff),
897
+ static_cast<std::uint8_t>((value >> 16) & 0xff),
898
+ static_cast<std::uint8_t>((value >> 24) & 0xff),
899
+ };
900
+ putBytes(buffer, bytes, 4);
901
+ }
902
+
903
+ void putU64(std::vector<std::uint8_t>& buffer, std::uint64_t value) {
904
+ std::uint8_t bytes[8];
905
+ for (int index = 0; index < 8; ++index) bytes[index] = static_cast<std::uint8_t>((value >> (index * 8)) & 0xff);
906
+ putBytes(buffer, bytes, 8);
907
+ }
908
+
909
+ void putI64(std::vector<std::uint8_t>& buffer, std::int64_t value) {
910
+ putU64(buffer, static_cast<std::uint64_t>(value));
911
+ }
912
+
913
+ void putF64(std::vector<std::uint8_t>& buffer, double value) {
914
+ putU64(buffer, std::bit_cast<std::uint64_t>(value));
915
+ }
916
+
917
+ void putString(std::vector<std::uint8_t>& buffer, const std::string& value) {
918
+ putU32(buffer, static_cast<std::uint32_t>(value.size()));
919
+ putBytes(buffer, value.data(), value.size());
920
+ }
921
+
922
+ void hashEntity(std::vector<std::uint8_t>& buffer, Entity& entity) {
923
+ putString(buffer, entity.ref);
924
+ putU64(buffer, entity.spawnId);
925
+ for (Component& component : entity.components) {
926
+ putString(buffer, kKindNames[static_cast<std::size_t>(component.kind)]);
927
+ switch (component.kind) {
928
+ case Kind::Transform2D:
929
+ putF64(buffer, component.position.x);
930
+ putF64(buffer, component.position.y);
931
+ putF64(buffer, component.velocity.x);
932
+ putF64(buffer, component.velocity.y);
933
+ putF64(buffer, component.heading);
934
+ break;
935
+ case Kind::TwinStickInput:
936
+ putF64(buffer, component.move.x);
937
+ putF64(buffer, component.move.y);
938
+ putF64(buffer, component.aim.x);
939
+ putF64(buffer, component.aim.y);
940
+ buffer.push_back(component.fire ? 1 : 0);
941
+ break;
942
+ case Kind::SpawnShield:
943
+ case Kind::Telegraph:
944
+ putF64(buffer, component.remaining);
945
+ break;
946
+ case Kind::WanderMover:
947
+ putF64(buffer, component.rerollTimer);
948
+ break;
949
+ case Kind::SeekMover:
950
+ putF64(buffer, component.aliveSeconds);
951
+ break;
952
+ case Kind::AutoFireWeapon:
953
+ putF64(buffer, component.cooldown);
954
+ putU64(buffer, component.nextBulletId);
955
+ break;
956
+ case Kind::Lifetime:
957
+ putF64(buffer, component.age);
958
+ break;
959
+ case Kind::Health:
960
+ putI64(buffer, component.hp);
961
+ break;
962
+ case Kind::ResourceCounter:
963
+ putI64(buffer, component.score);
964
+ putI64(buffer, component.deaths);
965
+ break;
966
+ default:
967
+ break;
968
+ }
969
+ }
970
+ }
971
+
972
+ std::string canonicalTickHash(Runtime& runtime) {
973
+ std::vector<std::uint8_t> buffer;
974
+ buffer.reserve(1 << 16);
975
+ putU64(buffer, static_cast<std::uint64_t>(runtime.tick));
976
+ for (const RngStreamRt& stream : runtime.rngStreams) {
977
+ putString(buffer, stream.id);
978
+ putU32(buffer, stream.state);
979
+ }
980
+ for (Entity& entity : runtime.statics) {
981
+ if (entity.alive) hashEntity(buffer, entity);
982
+ }
983
+ // pooled is maintained in spawn order == ascending spawnId
984
+ for (Entity& entity : runtime.pooled) {
985
+ if (entity.alive) hashEntity(buffer, entity);
986
+ }
987
+ return rundot::sha256Hex(buffer.data(), buffer.size());
988
+ }
989
+
990
+ } // namespace
991
+
992
+ F64RunResult runReplayF64(const PackDef& pack, const ReplayDef& replay, bool hashEveryTick) {
993
+ Runtime runtime = createRuntime(pack);
994
+ std::map<std::int64_t, const std::vector<SlotInputDef>*> inputsByTick;
995
+ for (const InputFrameDef& frame : replay.inputFrames) {
996
+ inputsByTick[frame.tick] = &frame.slots;
997
+ }
998
+ std::vector<SlotInputDef> currentInputs(static_cast<std::size_t>(pack.inputSlots));
999
+
1000
+ F64RunResult result;
1001
+ std::string streamAccum;
1002
+ for (std::int64_t tick = 0; tick < replay.tickCount; tick += 1) {
1003
+ const auto frame = inputsByTick.find(tick);
1004
+ if (frame != inputsByTick.end()) currentInputs = *frame->second;
1005
+ tickRuntime(runtime, currentInputs);
1006
+ if (hashEveryTick || tick == replay.tickCount - 1) {
1007
+ const std::string tickHash = canonicalTickHash(runtime);
1008
+ if (hashEveryTick) {
1009
+ result.tickHashes.push_back(tickHash);
1010
+ } else {
1011
+ result.tickHashes.assign(1, tickHash);
1012
+ }
1013
+ streamAccum += tickHash;
1014
+ }
1015
+ }
1016
+ result.finalDigest = rundot::sha256Hex(reinterpret_cast<const std::uint8_t*>(streamAccum.data()), streamAccum.size());
1017
+ result.completedTicks = replay.tickCount;
1018
+ std::int64_t alivePooled = 0;
1019
+ for (const Entity& entity : runtime.pooled) {
1020
+ if (entity.alive) alivePooled += 1;
1021
+ }
1022
+ // liveEntities() counts all statics (even dead) plus alive pooled
1023
+ result.liveEntitiesAtEnd = static_cast<std::int64_t>(runtime.statics.size()) + alivePooled;
1024
+ for (Entity& entity : runtime.statics) {
1025
+ Component* counter = findComponent(entity, Kind::ResourceCounter);
1026
+ if (counter != nullptr) {
1027
+ result.finalScore = counter->score;
1028
+ result.shipDeaths = counter->deaths;
1029
+ break;
1030
+ }
1031
+ }
1032
+ return result;
1033
+ }
1034
+
1035
+ // component_renderer_f64.cpp includes this .cpp verbatim (see its header
1036
+ // comment); it defines RUNDOT_F64RT_OMIT_SIM_STEPPER so the stepper's
1037
+ // external-linkage symbols are only emitted from this TU.
1038
+ #if !defined(RUNDOT_F64RT_OMIT_SIM_STEPPER)
1039
+
1040
+ struct F64SimStepper::Impl {
1041
+ Runtime runtime;
1042
+ };
1043
+
1044
+ F64SimStepper::F64SimStepper(const PackDef& pack) : impl_(std::make_unique<Impl>()) {
1045
+ impl_->runtime = createRuntime(pack);
1046
+ }
1047
+
1048
+ F64SimStepper::~F64SimStepper() = default;
1049
+
1050
+ void F64SimStepper::tick(const std::vector<SlotInputDef>& inputs) {
1051
+ tickRuntime(impl_->runtime, inputs);
1052
+ }
1053
+
1054
+ void F64SimStepper::liveView(std::vector<SimEntityView>& out) {
1055
+ Runtime& runtime = impl_->runtime;
1056
+ out.clear();
1057
+ for (const int handle : liveEntities(runtime)) {
1058
+ Entity& entity = entityAt(runtime, handle);
1059
+ if (!entity.alive) continue; // killed statics stay listed; never render them
1060
+ SimEntityView view;
1061
+ view.ref = entity.ref;
1062
+ view.spawnId = entity.spawnId;
1063
+ if (Component* transform = findComponent(entity, Kind::Transform2D)) {
1064
+ view.hasTransform = true;
1065
+ view.x = transform->position.x;
1066
+ view.y = transform->position.y;
1067
+ view.vx = transform->velocity.x;
1068
+ view.vy = transform->velocity.y;
1069
+ view.heading = transform->heading;
1070
+ }
1071
+ if (Component* telegraph = findComponent(entity, Kind::Telegraph)) {
1072
+ view.hasTelegraph = true;
1073
+ view.telegraphRemaining = telegraph->remaining;
1074
+ }
1075
+ out.push_back(std::move(view));
1076
+ }
1077
+ }
1078
+
1079
+ const std::vector<FrameEventDef>& F64SimStepper::frameEvents() const {
1080
+ return impl_->runtime.frameEvents;
1081
+ }
1082
+
1083
+ #endif // !defined(RUNDOT_F64RT_OMIT_SIM_STEPPER)
1084
+
1085
+ } // namespace rundot::f64rt