extends Control ## #507: WRONG button — full 60-tick capture: ring buffer, snapshot history, replay seed. ## Upgrade of the Sprint 9 MVP (#495). ## ## On F12: pause sim, show one-line prompt, save all ring buffer data, unpause. ## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/ ## - snapshot.json — single-tick point-in-time (MVP compat) ## - render.txt — client-side text render ## - description.txt — tester notes + room/tick/seed metadata ## - inputs.jsonl — last 60 ticks of PlayerInput (replay-compatible JSONL) ## - snapshots.jsonl — last 60 ticks of ObserverSnapshot (one JSON per line) ## - seed.txt — RNG seed for deterministic replay ## - screenshot.png — viewport capture taken before dialog opened ## ## Ring buffer: pre-allocated RING_SIZE arrays at startup. record_tick() is the ## public API for main.gd. _push_tick_inputs() / _push_tick_snapshot() are the ## internal implementations, exposed for unit testing (test_bug_report_ring_buffer.gd). ## ## Spec: inputs.jsonl is compatible with tooling/test-client --replay (replay.rs). ## Format: one JSON array per line, each array = Vec for that tick. signal capture_completed signal capture_cancelled const BG_COLOR := Color(0.05, 0.05, 0.08, 0.85) const BORDER_COLOR := Color("#4a9ebb") const TEXT_COLOR := Color("#c8d0e0") const FONT_SIZE := 14 const LABEL_FONT_SIZE := 13 const BOX_WIDTH := 500 const BOX_HEIGHT := 120 const PADDING := 16 # #507: Ring buffer capacity — 60 ticks of history const RING_SIZE := 60 var _line_edit: LineEdit = null var _active: bool = false var _captured_screenshot: Image = null # #507: Pre-allocated ring buffers (no per-tick allocation after _ready). # Input ring: replay-format PlayerInput arrays, one per tick. # Snapshot ring: ObserverSnapshot JSON strings, one per tick. # Separate heads and counts so each buffer can be tested independently. # Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count) # + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a # debug tool that is always active during Gauntlet play. var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts var _input_head: int = 0 # Next write index (0..RING_SIZE-1) var _input_count: int = 0 # Filled slot count (0..RING_SIZE) var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot var _snapshot_head: int = 0 var _snapshot_count: int = 0 func _ready() -> void: visible = false mouse_filter = Control.MOUSE_FILTER_STOP # Pre-allocate ring buffers — resize then fill sentinels. # The ring array itself never grows after _ready. Each write replaces the GDScript # reference in an existing slot (not a new allocation of the ring), though the input # Array stored per slot is a fresh ref each tick. _input_ring.resize(RING_SIZE) _snapshot_ring.resize(RING_SIZE) for i in range(RING_SIZE): _input_ring[i] = [] _snapshot_ring[i] = "" # --------------------------------------------------------------------------- # Public API for main.gd: record one tick's data # --------------------------------------------------------------------------- ## Record one tick. Called from main.gd on every server tick (snapshot arrival). ## - tick: current server tick number ## - snapshot_json: JSON.stringify(GameState.current_snapshot) ## - mapper_inputs: Array of InputMapper dicts (BUG_REPORT/OPEN_MENU excluded). ## These are in raw InputMapper format and will be converted to replay format. func record_tick(tick: int, snapshot_json: String, mapper_inputs: Array) -> void: # Convert mapper inputs to replay-compatible format, then push both buffers. var replay_inputs: Array = [] for inp in mapper_inputs: var ri := _to_replay_format(inp, tick) if not ri.is_empty(): replay_inputs.append(ri) _push_tick_inputs(tick, replay_inputs) _push_tick_snapshot(snapshot_json) # --------------------------------------------------------------------------- # Internal ring buffer operations (also exposed for tests) # --------------------------------------------------------------------------- ## Push replay-format inputs for one tick. inputs is Array of {tick, action} dicts. ## Overwrites oldest entry when buffer is full (circular eviction). @warning_ignore("unused_parameter") func _push_tick_inputs(_tick: int, inputs: Array) -> void: _input_ring[_input_head] = inputs _input_head = (_input_head + 1) % RING_SIZE if _input_count < RING_SIZE: _input_count += 1 ## Push a JSON-serialized ObserverSnapshot string for one tick. func _push_tick_snapshot(snapshot_json: String) -> void: _snapshot_ring[_snapshot_head] = snapshot_json _snapshot_head = (_snapshot_head + 1) % RING_SIZE if _snapshot_count < RING_SIZE: _snapshot_count += 1 ## Format the input ring buffer as JSONL for writing to inputs.jsonl. ## Returns a String with one JSON array per line, oldest to newest. ## Each line: Array of {tick, action} replay-format PlayerInput objects. func _format_inputs_jsonl() -> String: var lines: PackedStringArray = [] var start := (_input_head - _input_count + RING_SIZE) % RING_SIZE for i in range(_input_count): var idx := (start + i) % RING_SIZE lines.append(JSON.stringify(_input_ring[idx])) return "\n".join(lines) ## Format the snapshot ring buffer as JSONL for writing to snapshots.jsonl. ## Returns a String with one JSON string per line, oldest to newest. func _format_snapshots_jsonl() -> String: var lines: PackedStringArray = [] var start := (_snapshot_head - _snapshot_count + RING_SIZE) % RING_SIZE for i in range(_snapshot_count): var idx := (start + i) % RING_SIZE lines.append(_snapshot_ring[idx]) return "\n".join(lines) ## Return the RNG seed for seed.txt. Never returns null. ## Uses GameState.rng_seed if available; falls back to "unavailable" string. ## Note: rng_seed is u64 on the server. JSON encodes u64 as a number, which ## loses precision above 2^53 via float intermediary. When the server field ## lands, consider string-encoding the seed to preserve all 64 bits. func _get_current_seed() -> Variant: if GameState.rng_seed != null: return GameState.rng_seed return "unavailable" ## Convert one InputMapper dict to replay-compatible PlayerInput dict. ## Returns empty dict for client-only actions (BUG_REPORT, OPEN_MENU). ## Replay format: {"tick": N, "action": "MoveNorth"} or ## {"tick": N, "action": {"Interact": {"target_entity_id": ..., "verb": ...}}} func _to_replay_format(input: Dictionary, tick: int) -> Dictionary: var action_enum: int = input.get("action", -1) var wire: String = SimBridge.action_enum_to_wire(action_enum) if wire.is_empty(): return {} # Client-only action (BUG_REPORT, OPEN_MENU) var result := {"tick": tick} var action_data: Variant = input.get("action_data") match wire: "Interact": # Rust PlayerAction::Interact { target_entity_id, verb } result["action"] = {"Interact": action_data if action_data is Dictionary else {}} "SetFacing": # Rust PlayerAction::SetFacing(String) — wrap direction string var facing := "" if action_data is Dictionary: facing = str(action_data.get("facing", "")) result["action"] = {"SetFacing": facing} _: # Simple enum variants: "MoveNorth", "Pause", "TeleportToHub", etc. result["action"] = wire return result # --------------------------------------------------------------------------- # Test-accessible accessors (ring buffer introspection) # --------------------------------------------------------------------------- func _get_buffer_capacity() -> int: return RING_SIZE func _get_snapshot_buffer_capacity() -> int: return RING_SIZE func _get_input_buffer() -> Array: return _input_ring func _get_filled_input_count() -> int: return _input_count func _get_filled_snapshot_count() -> int: return _snapshot_count # --------------------------------------------------------------------------- # UI / capture flow # --------------------------------------------------------------------------- func start_capture() -> void: if _active: return # Capture screenshot BEFORE showing the dialog overlay _captured_screenshot = get_viewport().get_texture().get_image() _active = true visible = true # Pause the simulation ( SimBridge . send_input( { "action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec(), } ) ) # Create the LineEdit dynamically _line_edit = LineEdit.new() _line_edit.placeholder_text = "Describe the issue..." _line_edit.size = Vector2(BOX_WIDTH - PADDING * 2, 30) _line_edit.position = Vector2( (get_viewport_rect().size.x - BOX_WIDTH) / 2.0 + PADDING, (get_viewport_rect().size.y - BOX_HEIGHT) / 2.0 + 50 ) _line_edit.add_theme_font_size_override("font_size", FONT_SIZE) _line_edit.text_submitted.connect(_on_text_submitted) add_child(_line_edit) _line_edit.grab_focus() func _on_text_submitted(text: String) -> void: _save_report(text) _close() capture_completed.emit() func _unhandled_input(event: InputEvent) -> void: if not _active: return if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: _close() capture_cancelled.emit() get_viewport().set_input_as_handled() func _close() -> void: _active = false visible = false if _line_edit: _line_edit.release_focus() _line_edit.queue_free() _line_edit = null _captured_screenshot = null # Unpause the simulation ( SimBridge . send_input( { "action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec(), } ) ) func _save_report(description: String) -> void: var tick := GameState.current_tick var timestamp := Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_") var dir_name := "gauntlet-t%d-%s" % [tick, timestamp] var base_path := "user://bug-reports/" + dir_name # Ensure directory exists var dir_err := DirAccess.make_dir_recursive_absolute(base_path) if dir_err != OK: push_error("BugReport: failed to create directory %s (error %d)" % [base_path, dir_err]) return var files_saved := 0 # 1. snapshot.json — single-tick point-in-time (MVP compat, #495) var snapshot_path := base_path + "/snapshot.json" var snapshot_file := FileAccess.open(snapshot_path, FileAccess.WRITE) if snapshot_file: snapshot_file.store_string(JSON.stringify(GameState.current_snapshot, "\t")) snapshot_file.close() files_saved += 1 else: push_error("BugReport: failed to write %s" % snapshot_path) # 2. render.txt — simplified client-side text render of snapshot var render_path := base_path + "/render.txt" var render_file := FileAccess.open(render_path, FileAccess.WRITE) if render_file: render_file.store_string(_render_snapshot_text()) render_file.close() files_saved += 1 else: push_error("BugReport: failed to write %s" % render_path) # 3. description.txt — tester description + metadata # Room: uses GameState.room_id (v0.1: room name is the map identifier) var desc_path := base_path + "/description.txt" var desc_file := FileAccess.open(desc_path, FileAccess.WRITE) if desc_file: desc_file.store_string("Description: %s\n" % description) desc_file.store_string("Tick: %d\n" % tick) desc_file.store_string( "Room: %s\n" % str(GameState.room_id if GameState.room_id else "none") ) desc_file.store_string("Stance: %s\n" % GameState.player_stance) desc_file.store_string("Facing: %s\n" % GameState.player_facing) desc_file.store_string("Position: %s\n" % str(GameState.player_position)) desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system()) desc_file.store_string("RingBufferTicks: %d\n" % _input_count) desc_file.close() files_saved += 1 else: push_error("BugReport: failed to write %s" % desc_path) # 4. inputs.jsonl — last N ticks of PlayerInput (replay-compatible) # One JSON array per line. Empty array = idle tick. # Compatible with tooling/test-client --replay (replay.rs). var inputs_path := base_path + "/inputs.jsonl" var inputs_file := FileAccess.open(inputs_path, FileAccess.WRITE) if inputs_file: inputs_file.store_string(_format_inputs_jsonl()) inputs_file.close() files_saved += 1 else: push_error("BugReport: failed to write %s" % inputs_path) # 5. snapshots.jsonl — last N ticks of ObserverSnapshot, oldest to newest. var snaps_path := base_path + "/snapshots.jsonl" var snaps_file := FileAccess.open(snaps_path, FileAccess.WRITE) if snaps_file: snaps_file.store_string(_format_snapshots_jsonl()) snaps_file.close() files_saved += 1 else: push_error("BugReport: failed to write %s" % snaps_path) # 6. seed.txt — RNG seed for deterministic replay. # Server must include "rng_seed" (u64) in ObserverSnapshot for this to be populated. # If absent: includes a note on the required protocol change. var seed_path := base_path + "/seed.txt" var seed_file := FileAccess.open(seed_path, FileAccess.WRITE) if seed_file: var seed_val: Variant = _get_current_seed() seed_file.store_string(str(seed_val) + "\n") if seed_val == "unavailable": ( seed_file . store_string( "# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n" ) ) seed_file.close() files_saved += 1 else: push_error("BugReport: failed to write %s" % seed_path) # 7. screenshot.png — viewport capture taken before dialog opened if _captured_screenshot: var screenshot_path := base_path + "/screenshot.png" var img_err := _captured_screenshot.save_png(screenshot_path) if img_err == OK: files_saved += 1 else: push_error("BugReport: failed to write %s (error %d)" % [screenshot_path, img_err]) print( ( "BugReport: saved %d/7 files to %s (ring: %d ticks)" % [files_saved, base_path, _input_count] ) ) ## Simplified client-side text render of the current snapshot. ## MVP version — full fidelity via server's format_snapshot_text() is a stretch goal. func _render_snapshot_text() -> String: var lines: PackedStringArray = [] lines.append("=== Snapshot t%d ===" % GameState.current_tick) lines.append( ( "Player: %s facing %s (%s)" % [str(GameState.player_position), GameState.player_facing, GameState.player_stance] ) ) if GameState.game_time.size() > 0: lines.append( ( "Time: day %s, %s, %s" % [ str(GameState.game_time.get("day", "?")), str(GameState.game_time.get("day_phase", "?")), str(GameState.game_time.get("tick_rate", "?")) ] ) ) lines.append("") lines.append("Entities (%d):" % GameState.visible_entities.size()) for entity in GameState.visible_entities: var kind_str: String = "" if entity.has("kind") and entity.kind is Dictionary: kind_str = entity.kind.get("variant", "?") elif entity.has("kind") and entity.kind is String: kind_str = entity.kind var vis: String = entity.get("visibility", "?") lines.append( ( " #%s %s at (%s, %s) [%s]" % [ str(entity.get("entity_id", "?")), kind_str, str(entity.get("x", "?")), str(entity.get("y", "?")), vis ] ) ) lines.append("") lines.append("Visible tiles: %d" % GameState.visible_tiles.size()) if GameState.current_monologue != null: lines.append("Monologue: %s" % str(GameState.current_monologue.get("text", ""))) if GameState.current_dialogue != null: lines.append( ( "Dialogue: %s says '%s'" % [ str(GameState.current_dialogue.get("npc_name", "?")), str(GameState.current_dialogue.get("speech", "")) ] ) ) return "\n".join(lines) func _draw() -> void: if not _active: return var viewport_size := get_viewport_rect().size # Full-screen dim draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR) # Center box var box_pos := Vector2( (viewport_size.x - BOX_WIDTH) / 2.0, (viewport_size.y - BOX_HEIGHT) / 2.0 ) var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT)) draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95)) draw_rect(box_rect, BORDER_COLOR, false, 1.0) # Title var font := ThemeDB.fallback_font draw_string( font, box_pos + Vector2(PADDING, 24), "WRONG — Describe the issue (Enter to save, Esc to cancel):", HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR ) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- func is_active() -> bool: return _active