ravensight-playtest 0.5.2 → 0.6.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.
- package/README.md +249 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +92 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +165 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +578 -68
- package/src/commands/init.js +19 -3
- package/src/commands/profile.js +25 -6
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- package/templates/godot-3d/tools/selftest.gd +77 -0
|
@@ -19,9 +19,18 @@
|
|
|
19
19
|
#
|
|
20
20
|
# Requests are drained in _process, at most 8 per frame, so the scene tree is
|
|
21
21
|
# read at a consistent point between frames.
|
|
22
|
+
#
|
|
23
|
+
# LOCKSTEP (RAVENSIGHT_DRIVER_LOCKSTEP=1, set by the CLI beside --fixed-fps) is
|
|
24
|
+
# the deterministic mode. The game's clock is stopped from this node's _ready,
|
|
25
|
+
# before the main scene loads, and only moves through the `step` request: apply
|
|
26
|
+
# these inputs, run exactly this many ticks, stop, answer with a digest of the
|
|
27
|
+
# state. `_sim_tick` counts those ticks and nothing else, so the time a persona
|
|
28
|
+
# spends thinking is not in it. Parked frames still run, with a delta of zero:
|
|
29
|
+
# game code that counts frames rather than using delta can still tell how long
|
|
30
|
+
# it was parked, which is one of the limits docs/REPLAY.md names.
|
|
22
31
|
extends Node
|
|
23
32
|
|
|
24
|
-
const DRIVER_VERSION := "1.
|
|
33
|
+
const DRIVER_VERSION := "1.2.0"
|
|
25
34
|
const DEFAULT_PORT := 47800
|
|
26
35
|
const FEATURE_TAG := "ravensight_driver"
|
|
27
36
|
const ADDON_DIR := "ravensight_driver"
|
|
@@ -48,6 +57,13 @@ const ERR_HEADLESS := -32010
|
|
|
48
57
|
const ERR_NODE_NOT_FOUND := -32011
|
|
49
58
|
const ERR_ACTION_NOT_DEFINED := -32012
|
|
50
59
|
const ERR_HOOK := -32020
|
|
60
|
+
const ERR_NOT_LOCKSTEP := -32030
|
|
61
|
+
|
|
62
|
+
const MAX_STEP_TICKS := 3600
|
|
63
|
+
const MAX_STEP_EVENTS := 64
|
|
64
|
+
const MAX_DIGEST_SUBTREES := 64
|
|
65
|
+
const MAX_DRAW_WAIT_FRAMES := 30
|
|
66
|
+
const MAX_DIGEST_STATE_BYTES := 4096
|
|
51
67
|
|
|
52
68
|
var _server: TCPServer
|
|
53
69
|
var _peer: StreamPeerTCP
|
|
@@ -59,6 +75,19 @@ var _pending_waits: Array = []
|
|
|
59
75
|
var _last_scene_path := ""
|
|
60
76
|
var _extra_methods := {}
|
|
61
77
|
|
|
78
|
+
var _lockstep := false
|
|
79
|
+
var _sim_tick := 0
|
|
80
|
+
var _seed_value := 0
|
|
81
|
+
var _step_left := 0
|
|
82
|
+
var _step_id: Variant = null
|
|
83
|
+
var _step_digest := true
|
|
84
|
+
var _step_answer_next := false
|
|
85
|
+
var _resume_scale := 1.0
|
|
86
|
+
var _held_keys := {}
|
|
87
|
+
var _held_actions := {}
|
|
88
|
+
var _reasserted := 0
|
|
89
|
+
var _step_clicks: Array = []
|
|
90
|
+
|
|
62
91
|
## --- Lifecycle ----------------------------------------------------------------
|
|
63
92
|
|
|
64
93
|
func _ready() -> void:
|
|
@@ -68,6 +97,12 @@ func _ready() -> void:
|
|
|
68
97
|
queue_free()
|
|
69
98
|
return
|
|
70
99
|
|
|
100
|
+
# Before anything else, and before the main scene exists: a seed set after
|
|
101
|
+
# the first scene's _ready, or a clock stopped after its first frame, would
|
|
102
|
+
# already have let the wall clock into the run.
|
|
103
|
+
if OS.get_environment("RAVENSIGHT_DRIVER_LOCKSTEP") == "1":
|
|
104
|
+
_enter_lockstep(OS.get_environment("RAVENSIGHT_DRIVER_SEED"))
|
|
105
|
+
|
|
71
106
|
_token = OS.get_environment("RAVENSIGHT_DRIVER_TOKEN")
|
|
72
107
|
_authed = _token.is_empty()
|
|
73
108
|
|
|
@@ -145,6 +180,8 @@ func _on_sdk_event(event_name: String, data: Dictionary) -> void:
|
|
|
145
180
|
## --- Socket pump --------------------------------------------------------------
|
|
146
181
|
|
|
147
182
|
func _process(_delta: float) -> void:
|
|
183
|
+
if _lockstep:
|
|
184
|
+
_advance_lockstep()
|
|
148
185
|
_accept_connections()
|
|
149
186
|
_read_client()
|
|
150
187
|
_drain_inbox()
|
|
@@ -228,6 +265,7 @@ func _close_client() -> void:
|
|
|
228
265
|
_buffer = PackedByteArray()
|
|
229
266
|
_inbox.clear()
|
|
230
267
|
_pending_waits.clear()
|
|
268
|
+
_step_id = null
|
|
231
269
|
_authed = _token.is_empty()
|
|
232
270
|
|
|
233
271
|
|
|
@@ -277,6 +315,9 @@ func _handle_line(line: String) -> void:
|
|
|
277
315
|
"key": _rpc_key(id, params)
|
|
278
316
|
"click": _rpc_click(id, params)
|
|
279
317
|
"wait_frames": _rpc_wait_frames(id, params)
|
|
318
|
+
"seed": _rpc_seed(id, params)
|
|
319
|
+
"step": _rpc_step(id, params)
|
|
320
|
+
"digest": _send_result(id, _lockstep_answer(true))
|
|
280
321
|
"screenshot": _rpc_screenshot(id, params)
|
|
281
322
|
"set_time_scale": _rpc_set_time_scale(id, params)
|
|
282
323
|
"list_actions": _send_result(id, {"actions": _action_names()})
|
|
@@ -348,6 +389,11 @@ func _rpc_ping(id: Variant) -> void:
|
|
|
348
389
|
"frame": Engine.get_process_frames(),
|
|
349
390
|
"time_scale": Engine.time_scale,
|
|
350
391
|
"main_scene": scene.scene_file_path if scene != null else "",
|
|
392
|
+
"lockstep": _lockstep,
|
|
393
|
+
"user_dir": OS.get_user_data_dir(),
|
|
394
|
+
"tick": _sim_tick,
|
|
395
|
+
"seed": _seed_value,
|
|
396
|
+
"ticks_per_second": Engine.physics_ticks_per_second,
|
|
351
397
|
})
|
|
352
398
|
|
|
353
399
|
|
|
@@ -443,6 +489,11 @@ func _rpc_key(id: Variant, params: Dictionary) -> void:
|
|
|
443
489
|
if keycode == KEY_NONE:
|
|
444
490
|
_send_error(id, ERR_INVALID_PARAMS, "unknown keycode: %s" % str(params.get("keycode")))
|
|
445
491
|
return
|
|
492
|
+
var event := _send_key_event(keycode, params)
|
|
493
|
+
_send_result(id, {"ok": true, "keycode": keycode, "pressed": event.pressed})
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
func _send_key_event(keycode: int, params: Dictionary) -> InputEventKey:
|
|
446
497
|
var event := InputEventKey.new()
|
|
447
498
|
event.keycode = keycode
|
|
448
499
|
event.physical_keycode = keycode
|
|
@@ -453,7 +504,7 @@ func _rpc_key(id: Variant, params: Dictionary) -> void:
|
|
|
453
504
|
if unicode != 0:
|
|
454
505
|
event.unicode = unicode
|
|
455
506
|
Input.parse_input_event(event)
|
|
456
|
-
|
|
507
|
+
return event
|
|
457
508
|
|
|
458
509
|
|
|
459
510
|
func _rpc_click(id: Variant, params: Dictionary) -> void:
|
|
@@ -470,13 +521,40 @@ func _rpc_click(id: Variant, params: Dictionary) -> void:
|
|
|
470
521
|
var pos := Vector2.ZERO
|
|
471
522
|
if params.has("node_path"):
|
|
472
523
|
var target := get_node_or_null(NodePath(str(params.get("node_path"))))
|
|
473
|
-
|
|
524
|
+
var at: Variant = _click_position(target)
|
|
525
|
+
if at == null:
|
|
474
526
|
_send_error(id, ERR_NODE_NOT_FOUND, "clickable node not found: %s" % str(params.get("node_path")))
|
|
475
527
|
return
|
|
476
|
-
pos =
|
|
528
|
+
pos = at
|
|
477
529
|
else:
|
|
478
530
|
pos = Vector2(float(params.get("x", 0.0)), float(params.get("y", 0.0)))
|
|
531
|
+
_send_click_events(pos, params)
|
|
532
|
+
_send_result(id, {"ok": true, "position": [pos.x, pos.y]})
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
## Where a Control's centre is in the window. Mouse events are in window pixels and
|
|
536
|
+
## a stretched project draws its canvas at another size, so the canvas position alone
|
|
537
|
+
## landed somewhere else on any game with a stretch mode.
|
|
538
|
+
func _window_position(control: Control) -> Vector2:
|
|
539
|
+
return get_window().get_final_transform() * control.get_global_rect().get_center()
|
|
540
|
+
|
|
479
541
|
|
|
542
|
+
## Where a click on this node lands in the window, or null when it cannot be clicked.
|
|
543
|
+
## A Node3D is clicked where the active camera draws its origin; one behind the camera,
|
|
544
|
+
## or in a scene with no camera, is not on screen to click.
|
|
545
|
+
func _click_position(target: Node) -> Variant:
|
|
546
|
+
if target is Control:
|
|
547
|
+
return _window_position(target as Control)
|
|
548
|
+
if target is Node3D:
|
|
549
|
+
var camera := (target as Node3D).get_viewport().get_camera_3d()
|
|
550
|
+
var at := (target as Node3D).global_position
|
|
551
|
+
if camera == null or camera.is_position_behind(at):
|
|
552
|
+
return null
|
|
553
|
+
return get_window().get_final_transform() * camera.unproject_position(at)
|
|
554
|
+
return null
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
func _send_click_events(pos: Vector2, params: Dictionary) -> void:
|
|
480
558
|
var button := MOUSE_BUTTON_LEFT
|
|
481
559
|
match str(params.get("button", "left")):
|
|
482
560
|
"right": button = MOUSE_BUTTON_RIGHT
|
|
@@ -495,7 +573,6 @@ func _rpc_click(id: Variant, params: Dictionary) -> void:
|
|
|
495
573
|
click.pressed = pressed
|
|
496
574
|
click.double_click = pressed and bool(params.get("double", false))
|
|
497
575
|
Input.parse_input_event(click)
|
|
498
|
-
_send_result(id, {"ok": true, "position": [pos.x, pos.y]})
|
|
499
576
|
|
|
500
577
|
|
|
501
578
|
func _rpc_wait_frames(id: Variant, params: Dictionary) -> void:
|
|
@@ -518,7 +595,7 @@ func _rpc_screenshot(id: Variant, params: Dictionary) -> void:
|
|
|
518
595
|
if _is_headless():
|
|
519
596
|
_send_error(id, ERR_HEADLESS, "screenshot unavailable in headless mode")
|
|
520
597
|
return
|
|
521
|
-
await
|
|
598
|
+
await _await_post_draw()
|
|
522
599
|
var image := get_viewport().get_texture().get_image()
|
|
523
600
|
if image == null:
|
|
524
601
|
_send_error(id, ERR_HEADLESS, "viewport produced no image")
|
|
@@ -538,14 +615,34 @@ func _rpc_screenshot(id: Variant, params: Dictionary) -> void:
|
|
|
538
615
|
})
|
|
539
616
|
|
|
540
617
|
|
|
618
|
+
## The next drawn frame, or the last one. A window the desktop has covered or
|
|
619
|
+
## moved to another space stops drawing, frame_post_draw never fires, and an
|
|
620
|
+
## unbounded wait here hung the RPC until the client gave up on the run.
|
|
621
|
+
func _await_post_draw() -> void:
|
|
622
|
+
var drawn := [false]
|
|
623
|
+
var mark := func() -> void: drawn[0] = true
|
|
624
|
+
RenderingServer.frame_post_draw.connect(mark, CONNECT_ONE_SHOT)
|
|
625
|
+
for _i in MAX_DRAW_WAIT_FRAMES:
|
|
626
|
+
await get_tree().process_frame
|
|
627
|
+
if drawn[0]:
|
|
628
|
+
return
|
|
629
|
+
if RenderingServer.frame_post_draw.is_connected(mark):
|
|
630
|
+
RenderingServer.frame_post_draw.disconnect(mark)
|
|
631
|
+
|
|
632
|
+
|
|
541
633
|
func _rpc_set_time_scale(id: Variant, params: Dictionary) -> void:
|
|
542
634
|
var problem := _check_params(params, {"scale": "number", "adjust_physics": "boolean"})
|
|
543
635
|
if not problem.is_empty():
|
|
544
636
|
_send_error(id, ERR_INVALID_PARAMS, problem)
|
|
545
637
|
return
|
|
546
|
-
var previous := Engine.time_scale
|
|
638
|
+
var previous := _resume_scale if _lockstep else Engine.time_scale
|
|
547
639
|
var scale := clampf(float(params.get("scale", 1.0)), 0.0, 16.0)
|
|
548
|
-
|
|
640
|
+
# In lockstep the clock belongs to `step`, so this names the rate the next
|
|
641
|
+
# step runs at rather than starting the game.
|
|
642
|
+
if _lockstep:
|
|
643
|
+
_resume_scale = scale
|
|
644
|
+
else:
|
|
645
|
+
Engine.time_scale = scale
|
|
549
646
|
if bool(params.get("adjust_physics", false)) and scale > 0.0:
|
|
550
647
|
Engine.physics_ticks_per_second = maxi(1, int(round(60.0 * scale)))
|
|
551
648
|
_send_result(id, {"time_scale": scale, "previous": previous})
|
|
@@ -564,6 +661,307 @@ func _rpc_quit(id: Variant, params: Dictionary) -> void:
|
|
|
564
661
|
get_tree().quit(code)
|
|
565
662
|
|
|
566
663
|
|
|
664
|
+
## --- Lockstep -----------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
## Stops the clock and takes the seed. Runs in _ready, before the main scene, and
|
|
667
|
+
## first in every frame's _process (the lowest priority there is), which is what
|
|
668
|
+
## makes "this many ticks" exact: Engine.time_scale is read once at the top of an
|
|
669
|
+
## iteration, so a change made here takes effect from the next one, and no node
|
|
670
|
+
## ran a frame between this request and the stop.
|
|
671
|
+
func _enter_lockstep(seed_text: String) -> void:
|
|
672
|
+
_lockstep = true
|
|
673
|
+
process_priority = -2147483648
|
|
674
|
+
_seed_value = int(seed_text) if seed_text.is_valid_int() else 0
|
|
675
|
+
seed(_seed_value)
|
|
676
|
+
var ticks_text := OS.get_environment("RAVENSIGHT_DRIVER_TICKS")
|
|
677
|
+
if ticks_text.is_valid_int() and int(ticks_text) > 0:
|
|
678
|
+
Engine.physics_ticks_per_second = int(ticks_text)
|
|
679
|
+
Engine.physics_jitter_fix = 0.0
|
|
680
|
+
Engine.max_physics_steps_per_frame = 1
|
|
681
|
+
_resume_scale = Engine.time_scale if Engine.time_scale > 0.0 else 1.0
|
|
682
|
+
Engine.time_scale = 0.0
|
|
683
|
+
_set_physics_running(false)
|
|
684
|
+
# Godot drops every held key when its window loses focus, so a desktop that
|
|
685
|
+
# moved focus during a hold cut the hold short at a tick the wall clock chose.
|
|
686
|
+
# A window that never takes focus has nothing to lose. Input here is synthetic
|
|
687
|
+
# anyway, and the run no longer takes the developer's keyboard.
|
|
688
|
+
get_window().unfocusable = true
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
## A physics step with a delta of zero still moves things: a stack of rigid bodies
|
|
692
|
+
## came out differently depending on how many parked frames it sat through, which
|
|
693
|
+
## is a number the wall clock chooses. So the servers stop while the game is
|
|
694
|
+
## parked. A game paused on its own keeps them stopped, as SceneTree does.
|
|
695
|
+
func _set_physics_running(running: bool) -> void:
|
|
696
|
+
var active := running and not get_tree().paused
|
|
697
|
+
PhysicsServer2D.set_active(active)
|
|
698
|
+
PhysicsServer3D.set_active(active)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
func _advance_lockstep() -> void:
|
|
702
|
+
if _step_answer_next:
|
|
703
|
+
# The final tick ran to its end in the frame before this one, so the
|
|
704
|
+
# digest reads what that tick left and not what it was halfway through.
|
|
705
|
+
_step_answer_next = false
|
|
706
|
+
var id: Variant = _step_id
|
|
707
|
+
_step_id = null
|
|
708
|
+
_send_result(id, _lockstep_answer(_step_digest))
|
|
709
|
+
return
|
|
710
|
+
if _step_left > 0:
|
|
711
|
+
_reassert_held()
|
|
712
|
+
_sim_tick += 1
|
|
713
|
+
_step_left -= 1
|
|
714
|
+
if _step_left == 0:
|
|
715
|
+
# A rate the game set during the step is the game's own (a hit stop,
|
|
716
|
+
# a slow motion), so the next step resumes at it.
|
|
717
|
+
_resume_scale = Engine.time_scale
|
|
718
|
+
Engine.time_scale = 0.0
|
|
719
|
+
_set_physics_running(false)
|
|
720
|
+
_step_answer_next = true
|
|
721
|
+
return
|
|
722
|
+
if Engine.time_scale != 0.0:
|
|
723
|
+
_resume_scale = Engine.time_scale
|
|
724
|
+
Engine.time_scale = 0.0
|
|
725
|
+
# Nothing can move while parked, and an unthrottled loop would hold a core
|
|
726
|
+
# for as long as a persona thinks.
|
|
727
|
+
OS.delay_msec(2)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
## `seed{value}` reseeds the global generator. The seed the run starts on is the
|
|
731
|
+
## one from RAVENSIGHT_DRIVER_SEED, taken before the first scene; this is for a
|
|
732
|
+
## client that needs a known generator state again mid run.
|
|
733
|
+
func _rpc_seed(id: Variant, params: Dictionary) -> void:
|
|
734
|
+
var problem := _check_params(params, {"value": "number"})
|
|
735
|
+
if not problem.is_empty():
|
|
736
|
+
_send_error(id, ERR_INVALID_PARAMS, problem)
|
|
737
|
+
return
|
|
738
|
+
if not params.has("value"):
|
|
739
|
+
_send_error(id, ERR_INVALID_PARAMS, "value is required")
|
|
740
|
+
return
|
|
741
|
+
_seed_value = int(params.get("value"))
|
|
742
|
+
seed(_seed_value)
|
|
743
|
+
_send_result(id, {"seed": _seed_value, "lockstep": _lockstep, "tick": _sim_tick, "ticks_per_second": Engine.physics_ticks_per_second})
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
## `step{events, ticks, digest}`: apply the inputs, run exactly `ticks` ticks,
|
|
747
|
+
## stop, answer. One request, so nothing can land between the input and the first
|
|
748
|
+
## tick that sees it, on a persona's run or on a replay of it.
|
|
749
|
+
func _rpc_step(id: Variant, params: Dictionary) -> void:
|
|
750
|
+
if not _lockstep:
|
|
751
|
+
_send_error(id, ERR_NOT_LOCKSTEP, "step needs lockstep mode")
|
|
752
|
+
return
|
|
753
|
+
var problem := _check_params(params, {"ticks": "number", "events": "array", "digest": "boolean"})
|
|
754
|
+
if not problem.is_empty():
|
|
755
|
+
_send_error(id, ERR_INVALID_PARAMS, problem)
|
|
756
|
+
return
|
|
757
|
+
if _step_id != null:
|
|
758
|
+
_send_error(id, ERR_BUSY, "a step is already running")
|
|
759
|
+
return
|
|
760
|
+
var ticks: int = int(params.get("ticks", 1))
|
|
761
|
+
if ticks < 1 or ticks > MAX_STEP_TICKS:
|
|
762
|
+
_send_error(id, ERR_INVALID_PARAMS, "ticks must be 1 to %d" % MAX_STEP_TICKS)
|
|
763
|
+
return
|
|
764
|
+
var events: Array = params.get("events", [])
|
|
765
|
+
if events.size() > MAX_STEP_EVENTS:
|
|
766
|
+
_send_error(id, ERR_INVALID_PARAMS, "at most %d events in one step" % MAX_STEP_EVENTS)
|
|
767
|
+
return
|
|
768
|
+
# Every event is checked before the first is applied, so a refused step
|
|
769
|
+
# changes nothing.
|
|
770
|
+
var resolved: Array = []
|
|
771
|
+
for event in events:
|
|
772
|
+
var checked := _check_step_event(event)
|
|
773
|
+
if checked.has("code"):
|
|
774
|
+
_send_error(id, int(checked["code"]), str(checked["message"]), checked.get("data"))
|
|
775
|
+
return
|
|
776
|
+
resolved.append(checked)
|
|
777
|
+
_step_clicks = []
|
|
778
|
+
for checked in resolved:
|
|
779
|
+
_apply_step_event(checked)
|
|
780
|
+
_step_digest = bool(params.get("digest", true))
|
|
781
|
+
_step_id = id
|
|
782
|
+
_step_left = ticks
|
|
783
|
+
# A game that draws randf() in _process whatever its delta (a screen shake)
|
|
784
|
+
# spends the generator on every parked frame, and how many there were is the
|
|
785
|
+
# wall clock's choice. Reseeding from the tick puts the generator back on the
|
|
786
|
+
# one sequence this tick always gets, however long the game sat parked.
|
|
787
|
+
seed(_seed_value * 1000003 + _sim_tick)
|
|
788
|
+
Engine.time_scale = _resume_scale
|
|
789
|
+
_set_physics_running(true)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
## The desktop can take focus from a game mid hold, and Godot then drops every held
|
|
793
|
+
## key. The trace says the key is down, so it goes back down on the next tick. The
|
|
794
|
+
## count comes back in the digest: a run where this happened may be off by that much.
|
|
795
|
+
func _reassert_held() -> void:
|
|
796
|
+
for keycode in _held_keys:
|
|
797
|
+
if not Input.is_physical_key_pressed(keycode):
|
|
798
|
+
_reasserted += 1
|
|
799
|
+
_send_key_event(int(keycode), _held_keys[keycode])
|
|
800
|
+
for action in _held_actions:
|
|
801
|
+
if not Input.is_action_pressed(action):
|
|
802
|
+
_reasserted += 1
|
|
803
|
+
Input.action_press(action)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
func _check_step_event(raw: Variant) -> Dictionary:
|
|
807
|
+
if typeof(raw) != TYPE_DICTIONARY:
|
|
808
|
+
return {"code": ERR_INVALID_PARAMS, "message": "an event must be an object"}
|
|
809
|
+
var event: Dictionary = raw
|
|
810
|
+
match str(event.get("type", "")):
|
|
811
|
+
"key":
|
|
812
|
+
var keycode := _resolve_keycode(event.get("keycode"))
|
|
813
|
+
if keycode == KEY_NONE:
|
|
814
|
+
return {"code": ERR_INVALID_PARAMS, "message": "unknown keycode: %s" % str(event.get("keycode"))}
|
|
815
|
+
return {"type": "key", "keycode": keycode, "params": event}
|
|
816
|
+
"action":
|
|
817
|
+
var action := str(event.get("action", ""))
|
|
818
|
+
if not InputMap.has_action(action):
|
|
819
|
+
return {"code": ERR_ACTION_NOT_DEFINED, "message": "input action not defined: %s" % action, "data": {"available_actions": _action_names()}}
|
|
820
|
+
return {"type": "action", "action": action, "pressed": bool(event.get("pressed", true))}
|
|
821
|
+
"click":
|
|
822
|
+
var pos := Vector2(float(event.get("x", 0.0)), float(event.get("y", 0.0)))
|
|
823
|
+
if event.has("node_path"):
|
|
824
|
+
var target := get_node_or_null(NodePath(str(event.get("node_path"))))
|
|
825
|
+
var at: Variant = _click_position(target)
|
|
826
|
+
if at == null:
|
|
827
|
+
return {"code": ERR_NODE_NOT_FOUND, "message": "clickable node not found: %s" % str(event.get("node_path"))}
|
|
828
|
+
pos = at
|
|
829
|
+
return {"type": "click", "position": pos, "params": event}
|
|
830
|
+
return {"code": ERR_INVALID_PARAMS, "message": "unknown event type: %s" % str(event.get("type", ""))}
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
func _apply_step_event(event: Dictionary) -> void:
|
|
834
|
+
match str(event["type"]):
|
|
835
|
+
"key":
|
|
836
|
+
var key_params: Dictionary = event["params"]
|
|
837
|
+
_send_key_event(int(event["keycode"]), key_params)
|
|
838
|
+
if bool(key_params.get("pressed", true)):
|
|
839
|
+
_held_keys[int(event["keycode"])] = key_params
|
|
840
|
+
else:
|
|
841
|
+
_held_keys.erase(int(event["keycode"]))
|
|
842
|
+
"click":
|
|
843
|
+
var at: Vector2 = event["position"]
|
|
844
|
+
_step_clicks.append([at.x, at.y])
|
|
845
|
+
_send_click_events(at, event["params"])
|
|
846
|
+
"action":
|
|
847
|
+
var action := str(event["action"])
|
|
848
|
+
var pressed := bool(event["pressed"])
|
|
849
|
+
if pressed:
|
|
850
|
+
Input.action_press(action)
|
|
851
|
+
_held_actions[action] = true
|
|
852
|
+
else:
|
|
853
|
+
Input.action_release(action)
|
|
854
|
+
_held_actions.erase(action)
|
|
855
|
+
var input := InputEventAction.new()
|
|
856
|
+
input.action = action
|
|
857
|
+
input.pressed = pressed
|
|
858
|
+
Input.parse_input_event(input)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
func _lockstep_answer(with_digest: bool) -> Dictionary:
|
|
862
|
+
var out := {"tick": _sim_tick, "frame": Engine.get_process_frames(), "reasserted": _reasserted, "clicks": _step_clicks}
|
|
863
|
+
if with_digest:
|
|
864
|
+
out["digest"] = _state_digest()
|
|
865
|
+
return out
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
## What the trace compares: the current scene's nodes and the game's own
|
|
869
|
+
## _ravensight_state() hooks. Autoloads are left out on purpose. They hold what a
|
|
870
|
+
## run is allowed to differ on (session ids, an SDK's network queue, audio), and
|
|
871
|
+
## whatever they do to the game shows up in the scene. Floats are cut to three
|
|
872
|
+
## places; a name Godot generated (@Sprite2D@123) comes from a process-wide
|
|
873
|
+
## counter, so it is replaced with the node's position among its siblings.
|
|
874
|
+
func _state_digest() -> Dictionary:
|
|
875
|
+
var scene := get_tree().current_scene
|
|
876
|
+
var out := {"scene": scene.scene_file_path if scene != null else "", "nodes": 0, "truncated": false, "subtrees": {}}
|
|
877
|
+
var budget := {"left": MAX_NODES, "truncated": false, "count": 0}
|
|
878
|
+
var parts := PackedStringArray()
|
|
879
|
+
if scene != null:
|
|
880
|
+
parts.append(_digest_fields(scene, 0))
|
|
881
|
+
budget["count"] = 1
|
|
882
|
+
var index := 0
|
|
883
|
+
for child in scene.get_children():
|
|
884
|
+
if _is_driver_node(child):
|
|
885
|
+
index += 1
|
|
886
|
+
continue
|
|
887
|
+
var own := PackedStringArray()
|
|
888
|
+
_digest_node(child, index, own, budget)
|
|
889
|
+
var joined := "".join(own)
|
|
890
|
+
parts.append(joined)
|
|
891
|
+
if index < MAX_DIGEST_SUBTREES:
|
|
892
|
+
out["subtrees"][_digest_name(child, index)] = joined.sha256_text().substr(0, 12)
|
|
893
|
+
index += 1
|
|
894
|
+
var states := {}
|
|
895
|
+
var hooks: Array = get_tree().get_nodes_in_group(STATE_GROUP)
|
|
896
|
+
for node in hooks:
|
|
897
|
+
if not node.has_method("_ravensight_state"):
|
|
898
|
+
continue
|
|
899
|
+
var raw: Variant = node.call("_ravensight_state")
|
|
900
|
+
if typeof(raw) == TYPE_DICTIONARY:
|
|
901
|
+
states[str(node.get_path())] = _jsonify(raw, 0)
|
|
902
|
+
var state_text := JSON.stringify(states, "", true)
|
|
903
|
+
parts.append(state_text)
|
|
904
|
+
out["nodes"] = budget["count"]
|
|
905
|
+
out["truncated"] = budget["truncated"]
|
|
906
|
+
out["state_hash"] = "".join(parts).sha256_text().substr(0, 32)
|
|
907
|
+
if state_text.to_utf8_buffer().size() <= MAX_DIGEST_STATE_BYTES:
|
|
908
|
+
out["game_state"] = states
|
|
909
|
+
return out
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
func _digest_node(node: Node, index: int, parts: PackedStringArray, budget: Dictionary) -> void:
|
|
913
|
+
if int(budget["left"]) <= 0:
|
|
914
|
+
budget["truncated"] = true
|
|
915
|
+
return
|
|
916
|
+
budget["left"] = int(budget["left"]) - 1
|
|
917
|
+
budget["count"] = int(budget["count"]) + 1
|
|
918
|
+
parts.append(_digest_fields(node, index))
|
|
919
|
+
var child_index := 0
|
|
920
|
+
for child in node.get_children():
|
|
921
|
+
_digest_node(child, child_index, parts, budget)
|
|
922
|
+
child_index += 1
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
func _digest_name(node: Node, index: int) -> String:
|
|
926
|
+
var node_name := str(node.name)
|
|
927
|
+
return "#%d" % index if node_name.begins_with("@") else node_name
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
func _digest_fields(node: Node, index: int) -> String:
|
|
931
|
+
var fields := "%s|%s" % [node.get_class(), _digest_name(node, index)]
|
|
932
|
+
if node is CanvasItem:
|
|
933
|
+
fields += "|v%d" % int((node as CanvasItem).is_visible_in_tree())
|
|
934
|
+
if node is Node2D:
|
|
935
|
+
var n2 := node as Node2D
|
|
936
|
+
fields += "|%.3f,%.3f,%.3f" % [n2.global_position.x, n2.global_position.y, n2.global_rotation]
|
|
937
|
+
elif node is Node3D:
|
|
938
|
+
var n3 := node as Node3D
|
|
939
|
+
var p3 := n3.global_position
|
|
940
|
+
var a3 := n3.global_rotation
|
|
941
|
+
fields += "|v%d|%.3f,%.3f,%.3f|%.3f,%.3f,%.3f" % [int(n3.is_visible_in_tree()), p3.x, p3.y, p3.z, a3.x, a3.y, a3.z]
|
|
942
|
+
elif node is Control:
|
|
943
|
+
var rect := (node as Control).get_global_rect()
|
|
944
|
+
fields += "|%.3f,%.3f,%.3f,%.3f" % [rect.position.x, rect.position.y, rect.size.x, rect.size.y]
|
|
945
|
+
if node is CharacterBody2D:
|
|
946
|
+
var v2 := (node as CharacterBody2D).velocity
|
|
947
|
+
fields += "|%.3f,%.3f" % [v2.x, v2.y]
|
|
948
|
+
elif node is CharacterBody3D:
|
|
949
|
+
var v3 := (node as CharacterBody3D).velocity
|
|
950
|
+
fields += "|%.3f,%.3f,%.3f" % [v3.x, v3.y, v3.z]
|
|
951
|
+
elif node is RigidBody2D:
|
|
952
|
+
var r2 := (node as RigidBody2D).linear_velocity
|
|
953
|
+
fields += "|%.3f,%.3f" % [r2.x, r2.y]
|
|
954
|
+
elif node is RigidBody3D:
|
|
955
|
+
var r3 := (node as RigidBody3D).linear_velocity
|
|
956
|
+
fields += "|%.3f,%.3f,%.3f" % [r3.x, r3.y, r3.z]
|
|
957
|
+
var text := _text_of(node)
|
|
958
|
+
if not text.is_empty():
|
|
959
|
+
fields += "|t:" + text.substr(0, MAX_TEXT_CHARS)
|
|
960
|
+
if node is BaseButton:
|
|
961
|
+
fields += "|d%d" % int((node as BaseButton).disabled)
|
|
962
|
+
return fields + ";"
|
|
963
|
+
|
|
964
|
+
|
|
567
965
|
func _is_number_quad(value: Variant) -> bool:
|
|
568
966
|
if typeof(value) != TYPE_ARRAY:
|
|
569
967
|
return false
|