Expose live mod actors and difficulty and support dense event queries

This commit is contained in:
raptorreplica committed 2026-10-17 17:36:05 +11:00
1 parent 287b324e62
commit 93ea7064ba
10 files changed
+302 -11

No files matched your search

BIN
View File
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -159,7 +159,7 @@ fn on_fire(event, state, random) {
| `on_mission_start` | Mission: `tick = 0`. Runs when the first simulation step dispatches events. |
| `on_mission_end` | Mission: `success`, `tick`. |
Every dispatched event also includes `tick`, `player`, `entities`, `projectiles`, `pickups` and `objectives`. `player` is a snapshot including position, velocity, shields, credits, inventory and selected equipment. `entities` is the current enemy array; each entry includes `entity`, `definition`, position, `width`, `height`, health, animation, movement state and the definition's `ground` boolean. Filter on `ground` when an attack affects only airborne or ground targets. Projectiles and pickups likewise carry stable entity IDs. These are read-only snapshots: return commands to change the simulation. Later callbacks in a tick can observe commands committed by earlier callbacks. Events raised by commands, such as a spawned enemy, are dispatched in subsequent batches of the same tick, within the 8,192-event limit. Rejected command batches report the mod, script path and callback, and roll back that simulation step and script state.
Every World-dispatched event also includes `tick`, `difficulty`, `actor`, `player`, `entities`, `projectiles`, `pickups` and `objectives`. `difficulty` is the active World difficulty as an integer: `0` Training, `1` Rookie, `2` Veteran, `3` Elite. It is supplied centrally at dispatch for every hook, including events raised by commands; scripts can use it to select their own patterns. It does not change spawn difficulty gates or add a command that changes difficulty. Pilot saves retain difficulty alongside script state and RNG; recreating a mission from that pilot supplies the restored difficulty to its callbacks. `actor` is the addressed enemy's current snapshot, with exactly the same fields as its `entities` entry, including `ground`. It is `null` for mission callbacks, player entity `0`, nonenemy addresses and enemies already removed. A zero-health enemy still present in the World has an actor snapshot with `health = 0`; scripts should check presence and health before issuing commands. The actor is resolved when the callback dispatches, so it reflects movement, damage or removal committed by earlier callbacks; event-local `x`, `y` or `health` can still describe the earlier queued event. Scripts can read `event.actor` directly instead of scanning `event.entities` for themselves. This reuses the existing enemy-snapshot traversal and does not add another enemy scan or change the cost of constructing `entities`. `player` is a snapshot including position, velocity, shields, credits, inventory and selected equipment. `entities` is the current enemy array; each entry includes `entity`, `definition`, position, `width`, `height`, health, animation, movement state and the definition's `ground` boolean. Filter on `ground` when an attack affects only airborne or ground targets. Projectiles and pickups likewise carry stable entity IDs. These are read-only snapshots: return commands to change the simulation. Later callbacks in a tick can observe commands committed by earlier callbacks. Events raised by commands, such as a spawned enemy, are dispatched in subsequent batches of the same tick, within the 8,192-event limit. Rejected command batches report the mod, script path and callback, and roll back that simulation step and script state.
Use `behavior: "scripted"` for hitscan, chain lightning, proximity zaps or other attacks implemented entirely with commands. Player selection, firing input and weapon cooldowns still apply. An owned item with `special: false` fires its weapon alongside other ordinary equipment when Fire is held; `special: true` makes it selectable. Neither player nor enemy scripted weapons receive a default engine projectile. A script may explicitly emit `projectile`, or pair `damage` with `arc` for an immediate visible discharge. `target` describes the weapon's capability for the shop and governs explicit projectiles; scripts must enforce the intended targets and range for their own `damage` commands.
@@ -216,7 +216,7 @@ Lightning arcs render in both SD and HD using irregular fractal channels, thin t
`anchor` attaches the starting endpoint, and `to_anchor` independently attaches the destination. Supply absolute world coordinates at creation; each endpoint preserves its offset from the anchor's top-left `x/y` origin as the ship moves. Omitting an anchor keeps that endpoint fixed. Both anchors may be `0` to make a ring segment follow the player, or use `anchor: 0` and `to_anchor: enemy.entity` for a ship-to-ship bolt. Anchor IDs must exist when the command runs; a missing anchor rejects the batch. Endpoints update after simulation movement and API movement commands, and freeze at their last position when the anchored enemy disappears. Cosmetic coordinates clamp to ±32,768 when an attached ship moves beyond those bounds.
Rhai uses an explicit package allowlist for arithmetic, strings, collections, iteration and function helpers. The default language/time packages are excluded, so wall-clock timestamps and native `sleep` cannot be reached, including through function pointers. There are no exposed filesystem, network or OS interfaces. Dynamic `eval` and imports are disabled. Limits are 100,000 operations per callback and a shared 500,000-operation budget per simulation tick; 32 call levels; expression depths of 64/32; arrays of 4,096 entries; maps of 1,024 entries; strings of 16 KiB; 256 commands per callback; and 64 KiB of serialized state per script. See the [Rhai execution-limit API](https://docs.rs/rhai/latest/rhai/struct.Engine.html) for the underlying engine controls. Scripts cannot increase these limits.
Rhai uses an explicit package allowlist for arithmetic, strings, collections, iteration and function helpers. The default language/time packages are excluded, so wall-clock timestamps and native `sleep` cannot be reached, including through function pointers. There are no exposed filesystem, network or OS interfaces. Dynamic `eval` and imports are disabled. Limits are 100,000 operations per callback and a shared 500,000-operation budget per simulation tick; 32 call levels; expression depths of 64/32; arrays of 4,096 entries; object maps with at most 16,384 aggregate properties across their nested map tree; strings of 16 KiB; 256 commands per callback; and 64 KiB of serialized state per script. See the [Rhai execution-limit API](https://docs.rs/rhai/latest/rhai/struct.Engine.html) for the underlying engine controls. [Rhai counts nested properties recursively](https://rhai.rs/book/safety/max-map-size.html), so the map bound accommodates complete enemy/projectile snapshots rather than only a map's top-level fields. The separate 64 KiB serialized callback-state and command/operation bounds remain unchanged. Scripts cannot increase these limits.
## Saves and compatibility
+8
View File
@@ -15,6 +15,14 @@ Receipts for this one-picture append are in
`.reference/hd-root-next-publication-after5339/`; immutable intake is
`handoff-5340-20261017`.
The included Linux and Windows runtimes now expose active script difficulty and
current enemy snapshots. A finite 16,384-property Rhai map limit fixes documented
queries failing on ordinary enemy groups; operation, command and 64 KiB saved-state
limits remain unchanged. All 45 modding regression tests pass. Three actual 1080p
SDL captures (menu, S1 Wave 7, S3 Wave 9) match the previous binary byte for byte.
Windows cross-build, PE imports and shared-data verification pass; this source
revision still needs its own native Windows CI result.
Iron Coast retuning is active in `.reference/campaign-retune-20261017/`.
The baseline covers all 27 original and 14 mod missions at all four difficulties.
Mission waves, enemy movement, bosses and weapon presentation are being validated
+14
View File
@@ -1,5 +1,19 @@
# Rust engine and validation
## 17 October 2026: live mod snapshots and dense-event queries
World callbacks now receive `difficulty` and the addressed enemy's current `actor`
snapshot, including damage, movement and removal committed by earlier callbacks.
Dense enemy snapshots exposed Rhai's recursive map limit: even an ordinary query
could reject a 24-enemy event. The bounded limit is now 16,384 properties; existing
operation, command and serialized-state limits remain unchanged.
All 45 modding tests pass, including all four difficulties, save/RNG continuation,
stale callbacks, 64-enemy queries and oversized intermediate/output rejection.
Linux and Windows executables are rebuilt. Menu and two original-mission 1080p
SDL captures are byte-identical across the change (6,220,800 pixels compared).
Windows execution is verified separately by the post-push native workflow.
Rust is the default engine. The Rust binary never calls or embeds the C++ engine. SDL2 provides window/input/controller/audio services. Simulation and the validation CLI build without SDL2. The retained C++ checkout is optional reference material.
| Module | Responsibility |
+7 -7
View File
@@ -3,7 +3,7 @@
"platform": "Linux x86_64",
"catalog_entries": 6449,
"approved_hd_frames": 5340,
"binary_sha256": "ff6a6f08586a7950ee6bfcaba14da89409df63fbbd584d4255efc2b2ecff836f",
"binary_sha256": "b0e5f424cb60301f34e5e0088a9d00ad10ae609cd70b4e72be560376d952ed83",
"data_files": 9324,
"data_bytes": 8153633623,
"files": {
@@ -9333,11 +9333,11 @@
"data/ui/WINGAME_SWD": "304630a1223419d69723902ffd22c2c4eea0388491e37dfca663fcbe78e99aff"
},
"system_dependencies": [
"\tlinux-vdso.so.1 (0x00007f8e2614b000)",
"\tlibSDL2-2.0.so.0 => /home/quintine/Projects/RaptorReplica/RustRaptor/lib/libSDL2-2.0.so.0 (0x00007f8e25400000)",
"\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f8e260f7000)",
"\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007f8e256c9000)",
"\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007f8e25000000)",
"\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f8e2614d000)"
"\tlinux-vdso.so.1 (0x00007f7cf6301000)",
"\tlibSDL2-2.0.so.0 => /home/quintine/Projects/RaptorReplica/RustRaptor/lib/libSDL2-2.0.so.0 (0x00007f7cf5400000)",
"\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f7cf62ad000)",
"\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007f7cf6176000)",
"\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007f7cf5000000)",
"\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f7cf6303000)"
]
}
+1 -1
View File
@@ -37,7 +37,7 @@
]
},
"files": {
"bin/raptor-rust.exe": "427140b2b8b773979db46d9647728835af529082bee8503e6fa4eaaae37d4bee",
"bin/raptor-rust.exe": "28826f75f8952ac624f5058161e73d1a7c78a9ecc38f0e95635b327367ffba57",
"bin/SDL2.dll": "53e8fa7e9ed43c30bd650c39396f0d9d2067d2fff0b926c9433b75202592e4aa",
"bin/SDL2-WINDOWS-LICENSE.txt": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127",
"run.cmd": "b007382d9ed3560191856514a9ebd4e429801fc25428840e304bb297f3553269"
+4 -1
View File
@@ -708,7 +708,10 @@ impl ScriptRuntime {
.set_max_call_levels(32)
.set_max_expr_depths(64, 32)
.set_max_array_size(4096)
.set_max_map_size(1024)
// Rhai counts nested map properties, including host event snapshots.
// A 64-enemy event already has 2,886 properties. Keep inputs bounded
// without relaxing the separate 64 KiB state or operation limits.
.set_max_map_size(16_384)
.set_max_string_size(16384)
.set_max_modules(0);
engine.disable_symbol("eval");
+13
View File
@@ -2887,6 +2887,15 @@ impl World {
if let Some(path) = script {
let owner = &registry.owners[&event.content];
let mut e = event.event;
e["difficulty"] = self.difficulty.into();
let actor_entity = if registry.missions.contains_key(&event.content) {
None
} else {
e.get("entity")
.and_then(serde_json::Value::as_u64)
.filter(|entity| *entity != 0)
};
let mut actor = serde_json::Value::Null;
e["entities"] = serde_json::to_value(&self.enemies)?;
for (snapshot, enemy) in e["entities"]
.as_array_mut()
@@ -2895,7 +2904,11 @@ impl World {
.zip(&self.enemies)
{
snapshot["ground"] = registry.enemies[&enemy.definition].ground.into();
if actor_entity == Some(enemy.entity) {
actor = snapshot.clone();
}
}
e["actor"] = actor;
e["player"] = serde_json::to_value(&self.player)?;
e["objectives"] = serde_json::to_value(&self.objectives)?;
e["projectiles"] = serde_json::to_value(&self.projectiles)?;
+253
View File
@@ -1055,6 +1055,259 @@ fn callback_arity_and_source_size_are_checked_before_activation() {
);
}
#[test]
fn every_dispatched_hook_receives_the_active_world_difficulty() {
let hooks = [
("base:sector_1_wave_1", "on_mission_start"),
("base:sector_1_wave_1", "on_mission_end"),
("base:sector_1_wave_1", "on_tick"),
("base:probe", "on_spawn"),
("base:probe", "on_tick"),
("base:probe", "on_hit"),
("base:probe", "on_death"),
("base:projectile_forward_guns", "on_fire"),
("base:energy", "on_pickup"),
("base:energy", "on_use"),
];
let callbacks = [
"on_mission_start", "on_mission_end", "on_tick", "on_spawn",
"on_hit", "on_death", "on_fire", "on_pickup", "on_use",
];
let source = callbacks.iter().map(|hook| format!(
"fn {hook}(e,s,r) {{ s[\"{hook}\"] = e.difficulty; #{{commands:[],state:s}} }}"
)).collect::<Vec<_>>().join("\n");
for difficulty in 0..=3 {
let mut registry = base();
registry.apply(pack(json!({"enemies":[{
"id":"base:probe","name":"Probe","health":10,"speed":0,
"script":"difficulty.rhai"
}]})), "base").unwrap();
registry.missions.get_mut(&ContentId::from("base:sector_1_wave_1")).unwrap().script =
Some("difficulty.rhai".into());
registry.weapons.get_mut(&ContentId::from("base:projectile_forward_guns")).unwrap().script =
Some("difficulty.rhai".into());
registry.items.get_mut(&ContentId::from("base:energy")).unwrap().script =
Some("difficulty.rhai".into());
let mut runtime = ScriptRuntime::new();
runtime.add("base", "difficulty.rhai", &source).unwrap();
let pilot = Pilot { difficulty, ..Pilot::default() };
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
// Exercise the real dispatcher with explicit hook fixtures, including
// directly queued events. The common World snapshot must override an
// event-local value rather than relying on individual event producers.
world.events.extend(hooks.iter().map(|&(content, hook)| {
raptor_replica::simulation::HookEvent {
content: content.into(), hook, event: json!({"difficulty":99}),
}
}));
world.step(Input::default(), &registry, &mut runtime).unwrap();
for hook in callbacks {
assert_eq!(runtime.states["base/difficulty.rhai"][hook], difficulty,
"{hook} must observe active difficulty {difficulty}");
}
}
}
#[test]
fn saved_pilot_difficulty_and_callback_state_rng_resume_together() {
let source = r#"fn on_tick(e,s,r) {
let n = if s.contains("n") { s.n + 1 } else { 1 };
#{commands:[],state:#{n:n,difficulty:e.difficulty,last:r[0]}}
}"#;
for difficulty in 0..=3 {
let mut registry = base();
registry.missions.get_mut(&ContentId::from("base:sector_1_wave_1")).unwrap().script =
Some("difficulty.rhai".into());
let mut pilot = Pilot { difficulty, ..Pilot::default() };
let mut runtime = ScriptRuntime::new();
runtime.add("base", "difficulty.rhai", source).unwrap();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
for _ in 0..3 {
world.step(Input::default(), &registry, &mut runtime).unwrap();
}
pilot.merge_runtime_snapshot(&[], &runtime.states, &runtime.rng);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pilot.json");
pilot.save(&path).unwrap();
let saved = Pilot::load(&path, &registry, &[]).unwrap();
assert_eq!(saved.difficulty, difficulty);
let mut resumed_runtime = ScriptRuntime::new();
resumed_runtime.add("base", "difficulty.rhai", source).unwrap();
resumed_runtime.states = saved.script_state.clone();
resumed_runtime.rng = saved.script_rng.clone();
// Pilot saves recreate a mission; they do not store a live World.
// The next callback still continues script state/RNG and receives the
// difficulty restored into the new World by World::new.
let mut resumed_world = World::new(&saved.mission, &saved, &registry).unwrap();
resumed_world.step(Input::default(), &registry, &mut resumed_runtime).unwrap();
world.step(Input::default(), &registry, &mut runtime).unwrap();
assert_eq!(resumed_runtime.states, runtime.states);
assert_eq!(resumed_runtime.rng, runtime.rng);
assert_eq!(resumed_runtime.states["base/difficulty.rhai"]["n"], 4);
assert_eq!(resumed_runtime.states["base/difficulty.rhai"]["difficulty"], difficulty);
}
}
#[test]
fn callback_actor_is_current_after_earlier_damage_move_and_despawn() {
let mut registry = base();
registry.apply(pack(json!({"enemies":[{
"id":"base:probe","name":"Probe","health":10,"speed":0,
"ground":true,"script":"actor.rhai"
}]})), "base").unwrap();
registry.missions.get_mut(&ContentId::from("base:sector_1_wave_1")).unwrap().script =
Some("actor.rhai".into());
registry.weapons.get_mut(&ContentId::from("base:projectile_forward_guns")).unwrap().script =
Some("actor.rhai".into());
let mut runtime = ScriptRuntime::new();
runtime.add("base", "actor.rhai", r#"
fn on_mission_start(e,s,r) {
s.mission_actor = e.actor;
#{commands:[],state:s}
}
fn on_fire(e,s,r) {
s.player_actor = e.actor;
#{commands:[],state:s}
}
fn on_spawn(e,s,r) {
s.live = e.actor;
s.live_entities = e.entities;
#{commands:[#{op:"damage",entity:e.entity,amount:7},
#{op:"move",entity:e.entity,x:90,y:60}],state:s}
}
fn on_tick(e,s,r) {
if !e.contains("entity") { return #{commands:[],state:s}; }
s.queued_health = e.health;
s.queued_x = e.x;
s.current = e.actor;
#{commands:[#{op:"damage",entity:e.entity,amount:3}],state:s}
}
fn on_hit(e,s,r) {
if !s.contains("dead") {
s.queued_hit_health = e.health;
s.dead = e.actor;
return #{commands:[#{op:"despawn",entity:e.entity}],state:s};
}
s.removed = e.actor;
#{commands:[],state:s}
}
"#).unwrap();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let entity = world.spawn(&ContentId::from("base:probe"), 20, 20, &registry).unwrap();
world.step(Input { fire: true, ..Input::default() }, &registry, &mut runtime).unwrap();
let state = &runtime.states["base/actor.rhai"];
assert_eq!(state["live"], state["live_entities"][0]);
assert_eq!(state["live"]["entity"], entity);
assert_eq!(state["live"]["ground"], true);
assert_eq!(state["live"]["health"], 10);
assert_eq!(state["queued_health"], 10);
assert_eq!(state["current"]["health"], 3);
assert_eq!(state["current"]["x"], 90);
assert_ne!(state["queued_x"], state["current"]["x"]);
// Both hits were queued before the first hit callback. The first callback
// sees the current zero health, then despawns; the second sees null.
assert_eq!(state["queued_hit_health"], 3);
assert_eq!(state["dead"]["health"], 0);
assert!(state.get("removed").is_some());
assert!(state.get("mission_actor").is_some());
assert!(state.get("player_actor").is_some());
assert_eq!(state["removed"], Value::Null);
assert_eq!(state["mission_actor"], Value::Null);
assert_eq!(state["player_actor"], Value::Null);
assert!(world.enemies.is_empty());
}
#[test]
fn natural_death_callback_has_no_retired_actor() {
let mut registry = base();
registry.apply(pack(json!({"enemies":[{
"id":"base:probe","name":"Probe","health":10,"speed":0,
"script":"retired.rhai"
}]})), "base").unwrap();
let mut runtime = ScriptRuntime::new();
runtime.add("base", "retired.rhai", r#"fn on_death(e,s,r) {
#{commands:[],state:#{actor:e.actor,entity:e.entity}}
}"#).unwrap();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let entity = world.spawn(&ContentId::from("base:probe"), 20, 20, &registry).unwrap();
world.apply_commands(&[Command::Damage { entity, amount:10 }], &registry).unwrap();
world.step(Input::default(), &registry, &mut runtime).unwrap();
assert_eq!(runtime.states["base/retired.rhai"]["entity"], entity);
assert_eq!(runtime.states["base/retired.rhai"]["actor"], Value::Null);
assert!(world.enemies.is_empty());
}
#[test]
fn sixty_four_enemy_world_events_allow_documented_map_and_array_queries() {
for difficulty in 0..=3 {
let mut registry = base();
registry.apply(pack(json!({"enemies":[{
"id":"base:probe","name":"Probe","health":100,"speed":0,
"script":"dense-map.rhai"
}]})), "base").unwrap();
let mut runtime = ScriptRuntime::new();
runtime.add("base", "dense-map.rhai", r#"fn on_spawn(e,s,r) {
let n = if s.contains("n") { s.n + 1 } else { 1 };
#{commands:[],state:#{n:n,has_actor:e.contains("actor"),
enemies:e.entities.len,difficulty:e.difficulty,health:e.actor.health}}
}"#).unwrap();
let pilot = Pilot { difficulty, ..Pilot::default() };
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
for i in 0..64 {
world.spawn(&ContentId::from("base:probe"),
20 + i % 8 * 32, i / 8 * 10, &registry).unwrap();
}
world.step(Input::default(), &registry, &mut runtime).unwrap();
let state = &runtime.states["base/dense-map.rhai"];
assert_eq!(world.tick, 1);
assert_eq!(state["n"], 64);
assert_eq!(state["has_actor"], true);
assert_eq!(state["enemies"], 64);
assert_eq!(state["difficulty"], difficulty);
assert_eq!(state["health"], 100);
}
}
#[test]
fn recursive_map_limit_allows_bounded_work_and_rejects_larger_trees() {
// 12 doublings produce12,286 aggregate properties;13 produce24,574.
// The final state is tiny, so rejection exercises the intermediate map
// bound rather than the separate serialized callback-state limit.
let source = |rounds| format!(r#"fn on_tick(e,s,r) {{
let m = #{{leaf:0}};
for i in 0..{rounds} {{ m = #{{left:m,right:m}}; }}
#{{commands:[],state:#{{done:true}}}}
}}"#);
let mut runtime = ScriptRuntime::new();
runtime.add("test", "nested.rhai", &source(12)).unwrap();
runtime.begin_tick();
runtime.invoke("test", "nested.rhai", "on_tick", &json!({}), 42).unwrap();
assert_eq!(runtime.states["test/nested.rhai"]["done"], true);
let before = runtime.checkpoints();
runtime.add("test", "nested.rhai", &source(13)).unwrap();
runtime.begin_tick();
let message = error(runtime.invoke("test", "nested.rhai", "on_tick", &json!({}), 42));
assert!(message.contains("object map too large"), "{message}");
assert_eq!(runtime.checkpoints(), before);
}
#[test]
fn larger_event_maps_do_not_relax_the_64k_returned_state_limit() {
let mut runtime = ScriptRuntime::new();
runtime.add("test", "state-cap.rhai", r#"fn on_tick(e,s,r) {
let values = [];
for i in 0..4000 { values.push(999999999999999999); }
#{commands:[],state:values}
}"#).unwrap();
runtime.begin_tick();
let message = error(runtime.invoke("test", "state-cap.rhai", "on_tick", &json!({}), 42));
assert!(message.contains("state exceeds 64 KiB"), "{message}");
assert!(runtime.states.is_empty());
assert!(runtime.rng.is_empty());
}
#[test]
fn callbacks_can_query_player_entities_projectiles_pickups_and_objectives() {
let mut registry = base();