Files
aetherbound-guild/runtime/tests/dev5/battle_live_capture_test.gd
T

1909 lines
87 KiB
GDScript

extends SceneTree
const EntryShell = preload("res://entry/entry_shell.gd")
const SettingsStore = preload("res://entry/settings_store.gd")
const DraftService = preload("res://entry/initial_company_draft_service.gd")
const ContractService = preload("res://entry/guild_contract_service.gd")
const FormationService = preload("res://entry/formation_risk_service.gd")
const BattleService = preload("res://entry/battle_service.gd")
const UITokens = preload("res://foundation/ui_tokens.gd")
const DRAFT_PATH := "user://dev5_battle_live_capture_draft.json"
const COMPANY_PATH := "user://dev5_battle_live_capture_company.json"
const SEED := "2468ace013579bdf2468ace013579bdf"
const CONTRACT_OP := "abcdef0123456789abcdef0123456789"
const HIRE_OP_1 := "11111111222222223333333344444444"
const HIRE_OP_2 := "55555555666666667777777788888888"
const ORDER_OP := "99999999aaaaaaaabbbbbbbbcccccccc"
const READY_OP := "ddddddddeeeeeeeeffffffff00000000"
const BATTLE_OP := "1234567890abcdef1234567890abcdef"
const INTERRUPT_BATTLE_OP := "fedcba0987654321fedcba0987654321"
# M109: internal M77 route-gate proxy bands. These are measurement proxies only;
# they are never Owner-accepted scale (see AGENTS.md B1/M109-I04).
const PROXY_BAND_ALLY := [0.12, 0.30]
const PROXY_BAND_ENEMY := [0.10, 0.34]
const SHARED_TEXT_TOKEN := "micro"
const RESOLVED_DELTA_NAME := "resolved-delta"
# M110 v002: active fixed-slot contract (approved/v002/text-slots.json) plus the
# engine-owned dynamic placement policy for BattleLiveEventDetail.
const HUD_FIXED_SLOTS := {
"BattleLiveTitle": {"slot_id": "encounter_identity", "norm": [0.025, 0.145, 0.31, 0.030]},
"BattleLiveEventState": {"slot_id": "current_event_state", "norm": [0.370, 0.140, 0.26, 0.040]},
"BattleLiveRecentEvents": {"slot_id": "recent_event_sequence", "norm": [0.170, 0.760, 0.550, 0.040]},
}
const HUD_RULE_SUBSLOTS := {
"BattleLiveNoRespawn": {"slot_id": "encounter_rule_A", "norm": [0.025, 0.220, 0.14875, 0.035]},
"BattleLiveNoReserve": {"slot_id": "encounter_rule_B", "norm": [0.18625, 0.220, 0.14875, 0.035]},
}
const HUD_RULE_BAND := [0.025, 0.220, 0.31, 0.035]
const DESIGN_SIZE := Vector2(1280.0, 720.0)
const SLOT_TOLERANCE_PX := 2.0
const RELATION_CORRIDOR := Rect2(0.440, 0.280, 0.220, 0.420)
const RELATION_MIN_CLEARANCE_PX := 4.0
const RELATION_FALLBACK_SOURCE_PX := Vector2(0.47 * 1280.0, 0.56 * 720.0)
const RELATION_FALLBACK_TARGET_PX := Vector2(0.67 * 1280.0, 0.56 * 720.0)
const CONTRAST_MIN := 4.5
const LONGEST_SHIPPED := {
"BattleLiveTitle": {"zh": "风镜初渡 · 自动", "en": "WINDGLASS · AUTO"},
"BattleLiveNoRespawn": {"zh": "无复活", "en": "NO RESPAWN"},
"BattleLiveNoReserve": {"zh": "无后备", "en": "NO RESERVE"},
"BattleLiveEventState": {"zh": "正在验证快照", "en": "OBSERVATION INTERRUPTED"},
"BattleLiveRecentEvents": {"zh": "敌方威胁已公开 · 决定性伤害改变结果", "en": "Enemy threat revealed · Decisive damage changed the outcome"},
"BattleLiveEventDetail": {"zh": "敌12 → 0号 · -33", "en": "E12 -> P0 · -33"},
}
var failures: Array[String] = []
var capture_count := 0
var output_root := ""
var viewport_size := Vector2i(1280, 720)
var fixture_index := 0
var locale := "en"
var text_scale := 1.0
var reference_viewport := false
var state_records: Array = []
var evidence_records: Array = []
var capture_by_name := {}
var non_opening_kind := ""
var non_opening_phase := -1
var action_event_id := ""
var waiting_copy := ""
var hud_gate_failures: Array[String] = []
var hud_gate_cache := {}
var relation_summary_cache := {}
func _initialize() -> void:
call_deferred("run_capture")
func run_capture() -> void:
fixture_index = int(OS.get_environment("ABG_CAPTURE_FIXTURE_INDEX"))
var fixtures := [
{"size": Vector2i(760, 360), "locale": "zh_CN", "scale": 1.0},
{"size": Vector2i(844, 390), "locale": "en", "scale": 1.0},
{"size": Vector2i(1280, 720), "locale": "zh_CN", "scale": 1.0},
{"size": Vector2i(1280, 720), "locale": "en", "scale": 1.0},
{"size": Vector2i(1280, 720), "locale": "zh_CN", "scale": 1.3},
{"size": Vector2i(1280, 720), "locale": "en", "scale": 1.3},
]
if fixture_index < 0 or fixture_index >= fixtures.size():
push_error("unknown BAT-002 live capture fixture %d" % fixture_index)
quit(1)
return
viewport_size = fixtures[fixture_index].size
locale = fixtures[fixture_index].locale
text_scale = float(fixtures[fixture_index].scale)
reference_viewport = viewport_size == Vector2i(1280, 720)
root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND
root.size = viewport_size
output_root = OS.get_environment("ABG_EVIDENCE_DIR")
if output_root.is_empty():
output_root = "/tmp/abg-bat002-live-candidate"
output_root = output_root.path_join("captures")
DirAccess.make_dir_recursive_absolute(output_root)
_cleanup()
var prepared := _create_prepared_company()
var started: Dictionary = _battle_service().begin_battle(BATTLE_OP, String(prepared.active_contract.formation.risk_options[1].risk_option_id)) if not prepared.is_empty() else {}
_expect(bool(started.get("ok", false)), "capture fixture creates one battle receipt")
if started.is_empty() or not bool(started.get("ok", false)):
_finish()
return
var viewport := SubViewport.new()
viewport.name = "BattleLiveTimelineCaptureViewport"
viewport.size = viewport_size
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var shell := await _new_shell(viewport)
shell.show_battle(started.company, false)
await _frames(5)
_expect(shell.battle_live_state == "waiting_for_next_event", "capture opens at waiting boundary")
var waiting := shell.find_child("BattleWaitingForNextEvent", true, false) as Label
_expect(waiting != null and not waiting.text.is_empty(), "waiting boundary keeps a nonblank reserved event slot")
if locale == "en" and waiting != null:
_expect(waiting.text == "Waiting for next event", "waiting boundary copy is exact")
waiting_copy = waiting.text if waiting != null else ""
# M107-P2 diagnosis: the previous harness advanced four steps straight from
# the waiting boundary, so forecast/committed/resolving all belonged to the
# phase-0 opening event. Its chrome is suppressed, so those three frames were
# byte-identical. Sample the opening boundary once, resolve it, then sample
# the first non-opening event for the four action states.
shell.toggle_battle_pause()
shell.advance_battle_phase()
await _frames(2)
_expect(shell.battle_live_state == "event_forecast", "opening forecast is reached")
_expect(String(shell.battle_live_event.get("kind", "")) == "opening", "the first sampled event is the phase-0 opening")
await _capture(viewport, shell, "01-opening", "boundary", {
"matrix_state": "",
"action_coverage": false,
"note": "phase-0 opening (BAT-001 to BAT-002 boundary); state word hidden by existing design (M106 F2 product decision); not counted toward forecast/committed/resolving coverage",
})
for _step in range(3):
shell.advance_battle_phase()
await _frames(2)
_expect(shell.battle_live_state == "event_resolved", "the opening event resolves")
shell.advance_battle_phase()
await _frames(2)
_expect(shell.battle_live_state == "waiting_for_next_event", "observation returns to the waiting boundary")
var waiting_again := shell.find_child("BattleWaitingForNextEvent", true, false) as Label
_expect(waiting_again != null and not waiting_again.text.is_empty() and waiting_again.text == waiting_copy, "waiting boundary reappears with the same nonblank copy")
shell.advance_battle_phase()
await _frames(2)
_expect(shell.battle_live_state == "event_forecast", "the first non-opening forecast is reached")
non_opening_kind = String(shell.battle_live_event.get("kind", ""))
non_opening_phase = int(shell.battle_live_event.get("phase", -1))
_expect(non_opening_kind != "" and non_opening_kind != "opening", "the first action event is not the opening beat")
_expect(non_opening_phase >= 1, "the first action event has a non-opening phase")
await _capture(viewport, shell, "02-forecast")
shell.advance_battle_phase()
await _frames(2)
await _capture(viewport, shell, "03-committed")
shell.advance_battle_phase()
await _frames(2)
await _capture(viewport, shell, "04-resolving")
shell.advance_battle_phase()
await _frames(2)
_expect(shell.battle_live_state == "event_resolved", "capture reaches resolved event boundary")
await _capture(viewport, shell, "05-resolved")
var opening_hash := String(Dictionary(capture_by_name.get("01-opening", {})).get("sha256", ""))
var action_hash := String(Dictionary(capture_by_name.get("02-forecast", {})).get("sha256", ""))
_expect(not opening_hash.is_empty() and not action_hash.is_empty() and opening_hash != action_hash, "the first action state differs from the opening capture")
var committed_hash := String(Dictionary(capture_by_name.get("03-committed", {})).get("sha256", ""))
var resolving_hash := String(Dictionary(capture_by_name.get("04-resolving", {})).get("sha256", ""))
_expect(not (action_hash == committed_hash and committed_hash == resolving_hash), "forecast/committed/resolving are no longer one repeated opening frame")
# M107 per-state evidence (product review 2026-09-21 §2.1): each action state
# must prove its own window and ownership, not just a pixel difference.
var forecast := Dictionary(capture_by_name.get("02-forecast", {}))
var committed := Dictionary(capture_by_name.get("03-committed", {}))
var resolving := Dictionary(capture_by_name.get("04-resolving", {}))
var resolved := Dictionary(capture_by_name.get("05-resolved", {}))
action_event_id = String(forecast.get("event_id", ""))
_expect(not action_event_id.is_empty(), "the action event carries an event_id")
_expect(String(committed.get("event_id", "")) == action_event_id, "committed keeps the forecast reservation id")
_expect(bool(forecast.get("state_word_visible", false)) and bool(committed.get("state_word_visible", false)) and bool(resolving.get("state_word_visible", false)), "forecast/committed/resolving expose a visible state word")
_expect(String(forecast.get("state_word", "")) != String(committed.get("state_word", "")), "committed state word is identifiable from forecast")
_expect(bool(forecast.get("event_id_visible", false)) and bool(committed.get("event_id_visible", false)), "forecast/committed keep the event identity visible")
_expect(String(forecast.get("target_id", "")) != "", "forecast names a target")
_expect(String(resolving.get("source_id", "")) != "" and String(resolving.get("target_id", "")) != "", "resolving keeps source and target")
_expect(not bool(forecast.get("delta_visible", false)) and not bool(committed.get("delta_visible", false)) and not bool(resolving.get("delta_visible", false)), "forecast/committed/resolving do not claim a settled result")
_expect(bool(resolved.get("delta_visible", false)) and String(resolved.get("delta_text", "")) != "", "the first-event resolved boundary exposes an engine delta slot")
_expect(int(forecast.get("next_phase_index", -1)) == int(forecast.get("phase", -2)), "forecast sits in the pre-commit window")
_expect(int(committed.get("next_phase_index", -1)) == int(committed.get("phase", -2)), "committed sits before the authoritative resolve")
_expect(int(resolving.get("next_phase_index", -1)) == int(resolving.get("phase", -2)), "resolving is the authoritative contact window")
_expect(int(resolved.get("next_phase_index", -1)) == int(resolved.get("phase", -2)) + 1, "resolved is post-commit")
var art := shell.find_child("BattleLiveArt", true, false)
_expect(art != null and String(art.get("event_kind")) == String(resolving.get("event_kind", "")) and String(art.get("source_side")) != "" and String(art.get("target_side")) != "", "resolving owns a source-to-target relation path")
await _frames(10)
var settled := _state_evidence(shell)
_expect(String(shell.battle_live_state) == "event_resolved", "resolved persists for inspection frames")
_expect(bool(settled.get("delta_visible", false)) and String(settled.get("delta_text", "")) == String(resolved.get("delta_text", "")), "the first-event resolved delta text persists across frames")
# M109-I01: the first non-opening event (phase 1 telegraph) commits amount=0,
# so its resolved copy is the placeholder. Prove that this placeholder is real
# and explicitly exclude it from the delta claim instead of asserting it.
_expect(int(resolved.get("amount", -1)) == 0 and String(resolved.get("delta_text", "")) == _delta_placeholder_copy(), "the first non-opening resolved event is the amount=0 placeholder copy")
resolved["delta_proof"] = false
resolved["delta_proof_note"] = "amount=0 placeholder; never satisfies the resolved delta proof (M109-I01)"
resolved["delta_representation"] = {
"kind": "engine_text_glyphs",
"text": String(resolved.get("delta_text", "")),
"visible": bool(resolved.get("delta_visible", false)),
"font_color": String(resolved.get("delta_font_color", "")),
"measured": true,
}
resolved["persistence_measured"] = {
"frames": 10,
"state_stable": String(shell.battle_live_state) == "event_resolved",
"delta_text_stable": String(settled.get("delta_text", "")) == String(resolved.get("delta_text", "")),
"delta_visible_stable": bool(settled.get("delta_visible", false)),
}
# M109: the resolved delta proof must come from a deterministic non-zero
# event. The four action states above stay on the first non-opening event;
# this separately named later view walks forward to the first event whose
# committed amount is non-zero and captures its resolved state with the
# authoritative event identity, amount, direction, source/target and text.
var delta_walk := 0
while (
delta_walk < 12
and String(shell.battle_live_state) != "outcome_locked"
and not (
String(shell.battle_live_state) == "event_resolved"
and int(shell.battle_live_event.get("amount", 0)) != 0
)
):
shell.advance_battle_phase()
await _frames(2)
delta_walk += 1
var proof_ready := (
String(shell.battle_live_state) == "event_resolved"
and int(shell.battle_live_event.get("amount", 0)) != 0
)
_expect(proof_ready, "a deterministic non-zero event reaches the resolved state before outcome")
if proof_ready:
var proof_direction := String(Dictionary(shell.battle_live_event.get("state_delta", {})).get("direction", "none"))
var proof_amount := int(shell.battle_live_event.get("amount", 0))
var expected_delta_copy := "%s%d" % ["-" if proof_direction == "loss" else "+", proof_amount]
_expect(proof_direction in ["loss", "gain"], "the non-zero proof event carries a legal state-delta direction")
await _capture(viewport, shell, RESOLVED_DELTA_NAME, "view", {
"role_note": "later deterministic non-zero event; authoritative resolved delta proof view",
"walk_phase_steps": delta_walk,
})
var proof_view := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
_expect(bool(proof_view.get("delta_visible", false)) and not String(proof_view.get("delta_text", "")).is_empty(), "resolved-delta exposes the engine delta text")
_expect(String(proof_view.get("delta_text", "")) == expected_delta_copy and String(proof_view.get("delta_text", "")) != _delta_placeholder_copy(), "resolved-delta text matches the non-zero amount and direction")
_expect(String(proof_view.get("event_id", "")) != "" and String(proof_view.get("source_id", "")) != "" and String(proof_view.get("target_id", "")) != "", "resolved-delta records event identity and source/target")
await _frames(5)
var proof_settled := _state_evidence(shell)
var persistence_measured := {
"frames": 5,
"state_stable": String(shell.battle_live_state) == "event_resolved",
"event_id_stable": String(proof_settled.get("event_id", "")) == String(proof_view.get("event_id", "")),
"amount_stable": int(proof_settled.get("amount", 0)) == int(proof_view.get("amount", 0)),
"direction_stable": String(proof_settled.get("state_delta_direction", "none")) == String(proof_view.get("state_delta_direction", "none")),
"delta_text_stable": String(proof_settled.get("delta_text", "")) == String(proof_view.get("delta_text", "")),
}
proof_view["persistence_measured"] = persistence_measured
_expect(bool(persistence_measured.state_stable) and bool(persistence_measured.event_id_stable) and bool(persistence_measured.delta_text_stable), "resolved-delta identity and text persist across inspection frames")
_expect(_delta_proof_status() == "PROVEN", "resolved-delta proof passes legal-direction and strict identity/text checks")
else:
resolved["delta_proof_status"] = "NOT_PROVEN"
failures.append("no deterministic non-zero resolved event before outcome")
# M109-I02: no self-authored ownership strings. The manifest derives the
# changed-fields list from measured node visibility, text, bounds, relation
# endpoints and the PNG hash after every capture is complete.
# M107-P2: static ownership and dynamic-on-neutral evidence views, taken from
# the resolved non-opening state through existing nodes only.
await _capture_static_only(viewport, shell)
await _capture_dynamic_neutral(viewport, shell)
shell.toggle_battle_pause()
await _frames(2)
_expect(shell.battle_live_state == "paused" and shell.battle_paused, "capture freezes paused state")
await _capture(viewport, shell, "06-paused")
shell.toggle_battle_pause()
while shell.battle_live_state != "event_resolving":
shell.advance_battle_phase()
await _frames(1)
if shell.battle_live_state == "outcome_locked":
break
_expect(shell.battle_live_state == "event_resolving", "capture reaches an authoritative resolving event")
if shell.battle_live_state == "event_resolving":
shell.battle_live_event.event_hash = "0".repeat(64)
shell.advance_battle_phase()
await _frames(2)
_expect(shell.battle_live_state == "invalid_event_data" and shell.battle_paused, "capture exposes invalid-event pause")
await _capture(viewport, shell, "07-invalid")
shell._battle_live_retry_snapshot()
await _frames(1)
_expect(shell.battle_live_state == "loading_resume", "capture exposes loading-resume validation")
await _capture(viewport, shell, "08-loading")
await _frames(3)
_expect(shell.battle_live_state == "event_resolved" and shell.battle_paused, "capture retry returns to the last verified event boundary")
shell.toggle_battle_pause()
var guard := 0
while shell.battle_live_state != "outcome_locked" and guard < 90:
if shell.battle_paused:
shell.toggle_battle_pause()
shell.advance_battle_phase()
await _frames(1)
guard += 1
_expect(shell.battle_live_state == "outcome_locked", "capture reaches immutable outcome")
await _capture(viewport, shell, "10-outcome")
shell.queue_free()
viewport.queue_free()
await _frames(12)
_cleanup()
var interrupted := _create_prepared_company()
var interrupted_start: Dictionary = _battle_service().begin_battle(INTERRUPT_BATTLE_OP, String(interrupted.active_contract.formation.risk_options[1].risk_option_id)) if not interrupted.is_empty() else {}
_expect(bool(interrupted_start.get("ok", false)), "interruption fixture creates a second isolated receipt")
if bool(interrupted_start.get("ok", false)):
var interrupt_viewport := SubViewport.new()
interrupt_viewport.name = "BattleLiveInterruptCaptureViewport"
interrupt_viewport.size = viewport_size
interrupt_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(interrupt_viewport)
var interrupt_shell := await _new_shell(interrupt_viewport)
interrupt_shell.show_battle(interrupted_start.company, false)
await _frames(4)
interrupt_shell._battle_live_interrupt("capture_background")
await _frames(2)
_expect(interrupt_shell.battle_live_state == "interrupted_background" and interrupt_shell.battle_paused, "capture exposes BAT-006 interruption handoff")
await _capture(interrupt_viewport, interrupt_shell, "09-interrupted")
interrupt_shell.queue_free()
interrupt_viewport.queue_free()
await _frames(12)
_finish()
func _finish() -> void:
_cleanup()
_evaluate_hud_gates()
var manifest_path := _write_manifest()
if failures.is_empty():
var state_names: Array = []
for record in state_records:
state_names.append(String(Dictionary(record).get("name", "")))
state_names.sort()
var proof := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
var text_summary := _text_minimum_summary()
var scale_summary := _actor_scale_summary()
var hud := _hud_gate_summary()
var relation := _relation_path_summary()
var packet_status := "COMPLETE" if failures.is_empty() else "INCOMPLETE"
var scale_pct := int(round(text_scale * 100.0))
print("ABG_DEV5_BATTLE_LIVE_CAPTURE_OK fixture=%d viewport=%dx%d locale=%s text_scale_pct=%d text_100=%s states=%s state_captures=%d views=%d captures=%d source=runtime" % [fixture_index, viewport_size.x, viewport_size.y, locale, scale_pct, str(scale_pct == 100), ",".join(state_names), state_records.size(), evidence_records.size(), capture_count])
print("ABG_M107_BAT002_CAPTURE_MANIFEST_OK fixture=%d viewport=%dx%d locale=%s opening=phase0 action_kind=%s action_phase=%d states=%d views=%d manifest=%s" % [fixture_index, viewport_size.x, viewport_size.y, locale, non_opening_kind, non_opening_phase, state_records.size(), evidence_records.size(), manifest_path.get_file()])
print("ABG_M109_BAT002_EVIDENCE_SEMANTICS_OK fixture=%d viewport=%dx%d locale=%s schema=m110.bat002.capture_manifest.v4 packet=%s resolved_delta=%s event=%s amount=%d direction=%s delta_text=%s placeholder_amount0=%s text_status=%s text_min_observed=%d text_token_px=%d scale_status=%s scale_proxy_all_pass=%s product_claim=false manifest=%s" % [fixture_index, viewport_size.x, viewport_size.y, locale, packet_status, _delta_proof_status(), String(proof.get("event_kind", "")), int(proof.get("amount", 0)), String(proof.get("state_delta_direction", "none")), String(proof.get("delta_text", "")), str(int(Dictionary(capture_by_name.get("05-resolved", {})).get("amount", -1)) == 0), String(text_summary.get("status", "NOT_PROVEN")), int(text_summary.get("minimum_observed_font_size", -1)), int(text_summary.get("shared_minimum_px", 0)), String(scale_summary.get("status", "NOT_PROVEN")), str(bool(scale_summary.get("all_proxy_pass", false))), manifest_path.get_file()])
print("ABG_M110_BATTLE_HUD_LEGIBILITY_OK fixture=%d viewport=%dx%d locale=%s text_scale_pct=%d reference=%s states=%d text_token=micro text_token_px=%d text_min_px=%d under_minimum=%d slot_alignment=%s rule_subslots_disjoint=%s relation_status=%s relation_events=%d fallback_anchor_uses=%d composition_variants=1 second_layout=false" % [fixture_index, viewport_size.x, viewport_size.y, locale, scale_pct, str(reference_viewport), state_records.size() + evidence_records.size(), int(text_summary.get("shared_minimum_px", 0)), int(text_summary.get("minimum_observed_font_size", -1)), int(text_summary.get("states_with_under_minimum", 0)), String(hud.get("slot_alignment", "FAIL")), str(bool(hud.get("rule_subslots_disjoint", false))), String(relation.get("status", "NOT_PROVEN")), int(relation.get("relation_events", 0)), int(relation.get("fallback_anchor_uses", 0))])
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _evaluate_hud_gates() -> void:
# M110 A1-A3 machine gates. A1/A2/A3 gate the reference viewports only; the
# smoke fixtures keep the same measurements recorded without product claims.
var text_summary := _text_minimum_summary()
if reference_viewport:
if int(text_summary.get("states_with_under_minimum", 0)) > 0:
failures.append("A1: %d captured states contain visible type below the %dpx applicable token" % [int(text_summary.get("states_with_under_minimum", 0)), _shared_text_minimum()])
elif String(text_summary.get("status", "")) != "PASS_MEASURED":
failures.append("A1: reference text census did not reach PASS_MEASURED")
var hud := _hud_gate_summary()
if reference_viewport and String(hud.get("slot_alignment", "FAIL")) != "PASS":
failures.append("A2: v002 slot alignment failed: %s" % str(hud.get("slot_alignment_failures", [])))
if reference_viewport and not bool(hud.get("rule_subslots_disjoint", false)):
failures.append("A2: encounter_rule subslots are not disjoint")
var relation := _relation_path_summary()
if reference_viewport:
if String(relation.get("status", "NOT_PROVEN")) != "PRESENT":
failures.append("A3: relation path measurement is %s" % String(relation.get("status", "NOT_PROVEN")))
if int(relation.get("fallback_anchor_uses", 0)) != 0:
failures.append("A3: %d authored events still resolved to fallback anchors" % int(relation.get("fallback_anchor_uses", 0)))
if int(relation.get("bridge_relation_events", 0)) < 1:
failures.append("A3: no bridge-authored event resolved its relation path")
if not bool(relation.get("proof_present", false)):
failures.append("A3: the proof event relation path is not PRESENT")
var contrast := _path_contrast_record()
if reference_viewport:
if String(contrast.get("status", "NOT_PROVEN")) != "MET":
failures.append("A5: path contrast %s (worst %.2f:1 < %.1f:1, %s)" % [
String(contrast.get("status", "NOT_PROVEN")),
float(contrast.get("worst_ratio", 0.0)),
CONTRAST_MIN,
String(contrast.get("reason", "measured local carrier")),
])
func _delta_proof_status() -> String:
return String(_delta_proof_evaluation().get("status", "NOT_PROVEN"))
func _delta_proof_evaluation() -> Dictionary:
# M109: the proof status itself folds in the strict checks, so a captured
# view can never be reported as PROVEN while it fails direction legality,
# identity, text formatting or persistence.
var proof := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
var amount := int(proof.get("amount", 0))
var direction := String(proof.get("state_delta_direction", "none"))
var delta_text := String(proof.get("delta_text", ""))
var visible := bool(proof.get("delta_visible", false))
var event_kind := String(proof.get("event_kind", ""))
var phase := int(proof.get("phase", -1))
var event_id := String(proof.get("event_id", ""))
var source_id := String(proof.get("source_id", ""))
var target_id := String(proof.get("target_id", ""))
var matrix_state := String(proof.get("matrix_state", ""))
var expected_delta_text := "" if amount == 0 else "%s%d" % ["-" if direction == "loss" else "+", amount]
var persistence: Dictionary = proof.get("persistence_measured", {})
var checks := {
"captured": not proof.is_empty(),
"resolved_state": matrix_state == "event_resolved",
"event_kind_present": not event_kind.is_empty(),
"phase_present": phase >= 0,
"amount_nonzero": amount != 0,
"legal_direction": direction in ["loss", "gain"],
"delta_visible": visible,
"delta_text_matches_amount_direction": not delta_text.is_empty() and delta_text == expected_delta_text,
"placeholder_excluded": not delta_text.is_empty() and delta_text != _delta_placeholder_copy(),
"event_identity_present": not event_id.is_empty() and not source_id.is_empty() and not target_id.is_empty(),
"persistence_identity_text_stable": (
bool(persistence.get("state_stable", false))
and bool(persistence.get("event_id_stable", false))
and bool(persistence.get("amount_stable", false))
and bool(persistence.get("direction_stable", false))
and bool(persistence.get("delta_text_stable", false))
),
}
var failed_checks: Array = []
for key in checks.keys():
if not bool(checks[key]):
failed_checks.append(String(key))
failed_checks.sort()
return {
"status": "PROVEN" if failed_checks.is_empty() else "NOT_PROVEN",
"checks": checks,
"failed_checks": failed_checks,
"expected_delta_text": expected_delta_text,
}
func _write_manifest() -> String:
var opening := Dictionary(capture_by_name.get("01-opening", {}))
var forecast := Dictionary(capture_by_name.get("02-forecast", {}))
var resolved := Dictionary(capture_by_name.get("05-resolved", {}))
var proof := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
var manifest := {
"schema": "m110.bat002.capture_manifest.v4",
"source": "runtime",
"packet_status": "COMPLETE" if failures.is_empty() else "INCOMPLETE",
"packet_failures": failures.size(),
"product_claim": false,
"fixture_index": fixture_index,
"viewport": "%dx%d" % [viewport_size.x, viewport_size.y],
"locale": locale,
"reference_viewport": reference_viewport,
"delta_proof_rule": "resolved delta proof requires a deterministic amount != 0 event; the amount=0 placeholder copy never satisfies it",
"boundaries": {
"waiting_for_next_event": {
"asserted": not waiting_copy.is_empty(),
"copy": waiting_copy,
"never_blank": not waiting_copy.is_empty(),
"matrix_state": "waiting_for_next_event",
},
"opening": {
"coverage": "boundary_pre_roll",
"matrix_state": "",
"action_coverage": false,
"state_word_visible": bool(opening.get("state_word_visible", false)),
"note": "phase-0 opening (BAT-001 to BAT-002 boundary); state word hidden by existing design (M106 F2 product decision); not counted toward forecast/committed/resolving coverage",
},
},
"action_event": {
"phase": non_opening_phase,
"kind": non_opening_kind,
"event_id": action_event_id,
"amount": int(forecast.get("amount", 0)),
"state_delta_direction": String(forecast.get("state_delta_direction", "none")),
"states": ["forecast", "committed", "resolving", "resolved"],
"resolved_placeholder": {
"event_id": String(resolved.get("event_id", "")),
"phase": int(resolved.get("phase", -1)),
"amount": int(resolved.get("amount", 0)),
"state_delta_direction": String(resolved.get("state_delta_direction", "none")),
"delta_text": String(resolved.get("delta_text", "")),
"delta_visible": bool(resolved.get("delta_visible", false)),
"delta_proof": false,
"reason": "amount=0 placeholder copy; VERIFIED + amount=0 never satisfies the delta proof",
},
},
"resolved_delta_proof": _resolved_delta_proof_record(proof),
"text_minimum": _text_minimum_summary(),
"text_scale": text_scale,
"text_token_px": _shared_text_minimum(),
"under_minimum": int(_text_minimum_summary().get("states_with_under_minimum", 0)),
"hud_legibility": _hud_legibility_record(),
"relation_path_summary": _relation_path_summary(),
"path_contrast_measurement": _path_contrast_record(),
"actor_scale_proxy": _actor_scale_summary(),
"product_findings": _product_findings(),
"ownership_deltas_measured": _ownership_delta_pairs(),
"states": state_records,
"evidence_views": evidence_records,
"failures": failures.duplicate(),
}
var path := output_root.path_join("bat002-capture-manifest-%dx%d-%s.json" % [viewport_size.x, viewport_size.y, locale])
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
failures.append("manifest write failed: %s" % path)
return ""
file.store_string(JSON.stringify(manifest, " "))
file.close()
return path
func _resolved_delta_proof_record(proof: Dictionary) -> Dictionary:
var amount := int(proof.get("amount", 0))
var direction := String(proof.get("state_delta_direction", "none"))
var delta_text := String(proof.get("delta_text", ""))
var visible := bool(proof.get("delta_visible", false))
var ownership: Dictionary = proof.get("ownership", {})
var source_side := String(ownership.get("art.relation_source_side", ""))
var target_side := String(ownership.get("art.relation_target_side", ""))
var evaluation := _delta_proof_evaluation()
return {
"status": String(evaluation.get("status", "NOT_PROVEN")),
"checks": evaluation.get("checks", {}),
"failed_checks": evaluation.get("failed_checks", []),
"expected_delta_text": String(evaluation.get("expected_delta_text", "")),
"persistence_measured": proof.get("persistence_measured", {}),
"view": RESOLVED_DELTA_NAME,
"event_kind": String(proof.get("event_kind", "")),
"event_id": String(proof.get("event_id", "")),
"phase": int(proof.get("phase", -1)),
"amount": amount,
"amount_nonzero": amount != 0,
"state_delta_direction": direction,
"source_id": String(proof.get("source_id", "")),
"target_id": String(proof.get("target_id", "")),
"matrix_state": String(proof.get("matrix_state", "")),
"delta_text": delta_text,
"delta_visible": visible,
"delta_font_color": String(proof.get("delta_font_color", "")),
"png_sha256": String(proof.get("sha256", "")),
"relation_path_measurement": {
"source_side": source_side,
"target_side": target_side,
"source_anchor_px": ownership.get("art.relation_source_anchor_px", []),
"target_anchor_px": ownership.get("art.relation_target_anchor_px", []),
"measured": bool(ownership.get("art.exists", false)),
"status": "PRESENT" if not source_side.is_empty() and not target_side.is_empty() else "NOT_PROVEN",
"reason": "BattleLiveArt measured source_side/target_side for this event; empty means no source-to-target relation path is drawn, recorded without repair",
},
"placeholder_excluded": "amount=0 placeholder copy is recorded separately and never satisfies the delta proof",
"measured": not proof.is_empty(),
}
func _ownership_delta_pairs() -> Array:
var pairs := [
["01-opening", "02-forecast"],
["02-forecast", "03-committed"],
["03-committed", "04-resolving"],
["04-resolving", "05-resolved"],
["05-resolved", RESOLVED_DELTA_NAME],
["01-opening", "05-resolved"],
]
var result: Array = []
for pair in pairs:
var from_name := String(pair[0])
var to_name := String(pair[1])
var from_record := Dictionary(capture_by_name.get(from_name, {}))
var to_record := Dictionary(capture_by_name.get(to_name, {}))
if from_record.is_empty() or to_record.is_empty():
result.append({
"from": from_name,
"to": to_name,
"method": "computed_from_measured_node_fields",
"status": "NOT_PROVEN",
"reason": "one or both captures are missing",
})
continue
var changes := _measured_changes(
Dictionary(from_record.get("ownership", {})),
Dictionary(to_record.get("ownership", {}))
)
var from_hash := String(from_record.get("sha256", ""))
var to_hash := String(to_record.get("sha256", ""))
if from_hash != to_hash:
changes.append({"field": "png_sha256", "from": from_hash.substr(0, 16), "to": to_hash.substr(0, 16), "changed": true})
result.append({
"from": from_name,
"to": to_name,
"method": "computed_from_measured_node_fields",
"status": "MEASURED" if not changes.is_empty() else "NO_CHANGE_MEASURED",
"changes": changes,
})
return result
func _measured_changes(from_fields: Dictionary, to_fields: Dictionary) -> Array:
var changes: Array = []
var keys: Array = from_fields.keys()
keys.sort()
for key in keys:
var field := String(key)
if not to_fields.has(field):
changes.append({"field": field, "from": from_fields[key], "to": null, "changed": true})
elif to_fields[field] != from_fields[field]:
changes.append({"field": field, "from": from_fields[key], "to": to_fields[field], "changed": true})
for key in to_fields.keys():
var field := String(key)
if not from_fields.has(field):
changes.append({"field": field, "from": null, "to": to_fields[key], "changed": true})
return changes
func _new_shell(parent: Node) -> Control:
var shell := EntryShell.new()
shell.auto_start = false
shell.initial_company_service.path = DRAFT_PATH
shell.initial_company_service.company_path = COMPANY_PATH
parent.add_child(shell)
await _frames(3)
var settings := SettingsStore.new().defaults()
settings.setup_completed = true
settings.locale = locale
settings.text_scale = text_scale
shell.settings = settings
shell.setup_draft = settings.duplicate(true)
return shell
func _capture(viewport: SubViewport, shell: Control, state_name: String, role := "state", extra := {}) -> Dictionary:
_verify_layout(shell, state_name)
await RenderingServer.frame_post_draw
var image := viewport.get_texture().get_image()
var path := output_root.path_join("battle-live-timeline-%dx%d-%s-%d-%s.png" % [viewport_size.x, viewport_size.y, locale, int(round(text_scale * 100.0)), state_name])
if image.is_empty() or image.get_width() != viewport_size.x or image.get_height() != viewport_size.y:
failures.append("invalid capture image for %s" % state_name)
return {}
if image.save_png(path) != OK:
failures.append("capture failed for %s" % state_name)
return {}
capture_count += 1
var record := {
"name": state_name,
"role": role,
"state": String(shell.battle_live_state),
"phase": int(shell.battle_live_event.get("phase", -1)),
"event_kind": String(shell.battle_live_event.get("kind", "")),
"file": path.get_file(),
"sha256": _file_sha256(path),
"path_contrast": _path_contrast(image, shell),
}
record.merge(_state_evidence(shell), true)
record.merge(extra, true)
capture_by_name[state_name] = record
if role == "state" or role == "boundary":
state_records.append(record)
else:
evidence_records.append(record)
return record
func _state_evidence(shell: Control) -> Dictionary:
var state_word := shell.find_child("BattleLiveEventState", true, false) as Label
var event_id_label := shell.find_child("BattleLiveEventId", true, false) as Label
var delta := shell.find_child("BattleLiveEventDelta", true, false) as Label
var amount := int(shell.battle_live_event.get("amount", 0))
var direction := String(Dictionary(shell.battle_live_event.get("state_delta", {})).get("direction", "none"))
return {
"matrix_state": _matrix_state_for(String(shell.battle_live_state)),
"next_phase_index": int(shell.battle_live_observation.get("next_phase_index", -1)),
"event_id": String(shell.battle_live_event.get("event_id", "")),
"source_id": String(shell.battle_live_event.get("actor_id", "")),
"target_id": String(shell.battle_live_event.get("target_id", "")),
"amount": amount,
"state_delta_direction": direction,
"delta_proof_eligible": amount != 0,
"state_word": state_word.text if state_word != null else "",
"state_word_visible": state_word != null and state_word.visible,
"event_id_visible": event_id_label != null and event_id_label.visible,
"delta_text": delta.text if delta != null else "",
"delta_visible": delta != null and delta.visible,
"delta_font_color": _color_hex(delta.get_theme_color("font_color")) if delta != null else "",
"ownership": _ownership_fields(shell),
"visible_text_census": _visible_text_census(shell),
"hud_measurements": _hud_measurements(shell),
"actor_scale_proxy": _actor_scale_proxy(shell),
}
func _delta_placeholder_copy() -> String:
return "已验证" if locale == "zh_CN" else "VERIFIED"
func _color_hex(color: Color) -> String:
return color.to_html(false)
func _rounded(value: float) -> float:
return snappedf(value, 0.1)
func _rounded_fraction(value: float) -> float:
return snappedf(value, 0.0001)
func _rect_values(control: Control) -> Array:
var rect := control.get_global_rect()
return [_rounded(rect.position.x), _rounded(rect.position.y), _rounded(rect.size.x), _rounded(rect.size.y)]
func _measure_label(label: Label, prefix: String, fields: Dictionary) -> void:
if label == null:
fields["%s.exists" % prefix] = false
fields["%s.measurement_status" % prefix] = "NOT_PROVEN"
return
fields["%s.exists" % prefix] = true
fields["%s.node_name" % prefix] = String(label.name)
fields["%s.visible" % prefix] = label.visible
fields["%s.visible_in_tree" % prefix] = label.is_visible_in_tree()
fields["%s.text" % prefix] = label.text
fields["%s.font_size" % prefix] = int(label.get_theme_font_size("font_size"))
fields["%s.rect" % prefix] = _rect_values(label)
func _rect_array(rect: Rect2) -> Array:
return [_rounded(rect.position.x), _rounded(rect.position.y), _rounded(rect.size.x), _rounded(rect.size.y)]
func _normalized_array(rect: Rect2) -> Array:
return [
_rounded_fraction(rect.position.x / DESIGN_SIZE.x),
_rounded_fraction(rect.position.y / DESIGN_SIZE.y),
_rounded_fraction(rect.size.x / DESIGN_SIZE.x),
_rounded_fraction(rect.size.y / DESIGN_SIZE.y),
]
func _design_slot(norm: Array) -> Rect2:
return Rect2(
float(norm[0]) * DESIGN_SIZE.x,
float(norm[1]) * DESIGN_SIZE.y,
float(norm[2]) * DESIGN_SIZE.x,
float(norm[3]) * DESIGN_SIZE.y
)
func _design_scale(canvas: Control) -> Vector2:
if canvas == null or canvas.size.x <= 0.0 or canvas.size.y <= 0.0:
return Vector2.ONE
return DESIGN_SIZE / canvas.size
func _design_rect(control: Control, canvas: Control) -> Rect2:
var global := control.get_global_rect()
if canvas == null:
return global
var local := global.position - canvas.get_global_rect().position
return Rect2(local * _design_scale(canvas), global.size * _design_scale(canvas))
func _label_ink_size(label: Label, rect: Rect2) -> Vector2:
var font := label.get_theme_font("font")
if font == null or label.text.is_empty():
return Vector2.ZERO
var font_size := int(label.get_theme_font_size("font_size"))
if label.autowrap_mode == TextServer.AUTOWRAP_OFF:
return font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
return font.get_multiline_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x, font_size)
func _ink_box_for(label: Label, rect: Rect2, ink: Vector2) -> Rect2:
if ink == Vector2.ZERO:
return rect
var x := rect.position.x
match label.horizontal_alignment:
HORIZONTAL_ALIGNMENT_CENTER: x = rect.position.x + (rect.size.x - ink.x) * 0.5
HORIZONTAL_ALIGNMENT_RIGHT: x = rect.end.x - ink.x
var y := rect.position.y
match label.vertical_alignment:
VERTICAL_ALIGNMENT_CENTER: y = rect.position.y + (rect.size.y - ink.y) * 0.5
VERTICAL_ALIGNMENT_BOTTOM: y = rect.end.y - ink.y
return Rect2(Vector2(x, y), ink)
func _design_ink_box(label: Label, canvas: Control) -> Rect2:
var rect := label.get_global_rect()
var ink := _label_ink_size(label, rect)
var box := _ink_box_for(label, rect, ink)
var canvas_origin := canvas.get_global_rect().position if canvas != null else Vector2.ZERO
return Rect2((box.position - canvas_origin) * _design_scale(canvas), box.size * _design_scale(canvas))
func _label_ink_margins(label: Label, rect: Rect2, ink: Vector2) -> Array:
var margin_x := 0.0 if rect.size.x <= 0.0 else 1.0 - ink.x / rect.size.x
var margin_y := 0.0 if rect.size.y <= 0.0 else 1.0 - ink.y / rect.size.y
return [snappedf(margin_x, 0.0001), snappedf(margin_y, 0.0001)]
func _hud_node_record(shell: Control, canvas: Control, node_name: String, slot_id: String, slot_norm: Array) -> Dictionary:
var label := shell.find_child(node_name, true, false) as Label
var record := {"node": node_name, "authored_slot_id": slot_id, "exists": label != null, "measurement_status": "NOT_PROVEN"}
if label == null:
return record
var rect := label.get_global_rect()
var design_rect := _design_rect(label, canvas)
var slot := _design_slot(slot_norm)
var corner_delta := [
absf(design_rect.position.x - slot.position.x),
absf(design_rect.position.y - slot.position.y),
absf(design_rect.end.x - slot.end.x),
absf(design_rect.end.y - slot.end.y),
]
var delta: float = corner_delta[0]
for value in corner_delta:
delta = maxf(delta, float(value))
var ink := _label_ink_size(label, rect)
var ink_box := _ink_box_for(label, rect, ink)
var design_ink := Rect2((ink_box.position - canvas.get_global_rect().position) * _design_scale(canvas), ink_box.size * _design_scale(canvas))
var margins := _label_ink_margins(label, rect, ink)
var ellipsized := label.text_overrun_behavior != TextServer.OVERRUN_NO_TRIMMING and ink.x > rect.size.x + 0.5
record.merge({
"visible": label.is_visible_in_tree(),
"text": label.text,
"font_size_px": int(label.get_theme_font_size("font_size")),
"applicable_token_px": _shared_text_minimum(),
"owner_rect_px": _rect_array(design_rect),
"owner_rect_normalized": _normalized_array(design_rect),
"slot_rect_px": _rect_array(slot),
"slot_delta_px": _rounded(delta),
"ink_box_px": _rect_array(design_ink),
"ink_inside_rect": (
design_ink.position.x >= design_rect.position.x - 0.5
and design_ink.position.y >= design_rect.position.y - 0.5
and design_ink.end.x <= design_rect.end.x + 0.5
and design_ink.end.y <= design_rect.end.y + 0.5
),
"ink_margin_ratio_x": margins[0],
"ink_margin_ratio_y": margins[1],
"lines_used": label.get_line_count(),
"visible_lines": label.get_visible_line_count(),
"ellipsized": ellipsized,
"measurement_status": "MEASURED",
}, true)
var shipped: Dictionary = LONGEST_SHIPPED.get(node_name, {})
var shipped_text := String(shipped.get("zh" if locale == "zh_CN" else "en", ""))
if not shipped_text.is_empty():
var font := label.get_theme_font("font")
var shipped_ink := font.get_string_size(shipped_text, HORIZONTAL_ALIGNMENT_LEFT, -1, int(label.get_theme_font_size("font_size"))) if font != null else Vector2.ZERO
var shipped_margins := _label_ink_margins(label, design_rect, shipped_ink)
record["longest_shipped_text"] = shipped_text
record["longest_shipped_ink_px"] = [_rounded(shipped_ink.x), _rounded(shipped_ink.y)]
record["longest_shipped_ink_inside_rect"] = shipped_ink.x <= design_rect.size.x + 0.5 and shipped_ink.y <= design_rect.size.y + 0.5
record["longest_shipped_margin_ratio_x"] = shipped_margins[0]
record["longest_shipped_margin_ratio_y"] = shipped_margins[1]
return record
func _rule_band_record(shell: Control, canvas: Control, nodes: Dictionary) -> Dictionary:
var a: Dictionary = Dictionary(nodes.get("BattleLiveNoRespawn", {}))
var b: Dictionary = Dictionary(nodes.get("BattleLiveNoReserve", {}))
var band := _design_slot(HUD_RULE_BAND)
var record := {
"exists": bool(a.get("exists", false)) and bool(b.get("exists", false)),
"band_rect_px": _rect_array(band),
"measurement_status": "NOT_PROVEN",
}
if not bool(record.exists):
return record
var ra := Rect2(a.owner_rect_px[0], a.owner_rect_px[1], a.owner_rect_px[2], a.owner_rect_px[3])
var rb := Rect2(b.owner_rect_px[0], b.owner_rect_px[1], b.owner_rect_px[2], b.owner_rect_px[3])
var union := ra.merge(rb)
var union_delta := maxf(maxf(absf(union.position.x - band.position.x), absf(union.position.y - band.position.y)), maxf(absf(union.end.x - band.end.x), absf(union.end.y - band.end.y)))
var disjoint := not ra.intersects(rb)
var neither_spans := ra.size.x < band.size.x - 0.5 and rb.size.x < band.size.x - 0.5
var gap := 0.0
if disjoint:
gap = maxf(ra.position.x - rb.end.x, rb.position.x - ra.end.x)
record.merge({
"subslot_a_rect_px": _rect_array(ra),
"subslot_b_rect_px": _rect_array(rb),
"subslot_a_rect_normalized": a.owner_rect_normalized,
"subslot_b_rect_normalized": b.owner_rect_normalized,
"union_rect_px": _rect_array(union),
"band_union_delta_px": _rounded(union_delta),
"disjoint": disjoint,
"subslot_gap_px": _rounded(gap),
"neither_spans_band": neither_spans,
"measurement_status": "MEASURED",
}, true)
return record
func _relation_label_record(shell: Control, canvas: Control) -> Dictionary:
var label := shell.find_child("BattleLiveEventDetail", true, false) as Label
var record := {"exists": label != null, "measurement_status": "NOT_PROVEN"}
if label == null:
return record
var design_rect := _design_rect(label, canvas)
var design_ink := _design_ink_box(label, canvas)
var corridor := Rect2(RELATION_CORRIDOR.position * DESIGN_SIZE, RELATION_CORRIDOR.size * DESIGN_SIZE)
record.merge({
"visible": label.is_visible_in_tree(),
"text": label.text,
"placement_id": String(label.get_meta("relation_placement_id", "")),
"rect_px": _rect_array(design_rect),
"rect_normalized": _normalized_array(design_rect),
"clearance_px": _rounded(float(label.get_meta("relation_clearance_px", -1.0))),
"lines": int(label.get_meta("relation_lines", 0)),
"token_px": int(label.get_meta("relation_token_px", 0)),
"ink_box_px": _rect_array(design_ink),
"center_in_corridor": corridor.has_point(design_rect.get_center()),
"ink_inside_corridor": (
design_ink.position.x >= corridor.position.x - 0.5
and design_ink.position.y >= corridor.position.y - 0.5
and design_ink.end.x <= corridor.end.x + 0.5
and design_ink.end.y <= corridor.end.y + 0.5
),
"corridor_rect_px": _rect_array(corridor),
"measurement_status": "MEASURED",
}, true)
return record
func _hud_measurements(shell: Control) -> Dictionary:
var canvas := shell.find_child("BattleLiveCanvas", true, false) as Control
var nodes := {}
for node_name in HUD_FIXED_SLOTS.keys():
var spec: Dictionary = HUD_FIXED_SLOTS[node_name]
nodes[node_name] = _hud_node_record(shell, canvas, node_name, String(spec.slot_id), spec.norm)
for node_name in HUD_RULE_SUBSLOTS.keys():
var spec: Dictionary = HUD_RULE_SUBSLOTS[node_name]
nodes[node_name] = _hud_node_record(shell, canvas, node_name, String(spec.slot_id), spec.norm)
return {
"nodes": nodes,
"rule_band": _rule_band_record(shell, canvas, nodes),
"relation_label": _relation_label_record(shell, canvas),
}
func _hud_gate_summary() -> Dictionary:
if not hud_gate_cache.is_empty():
return hud_gate_cache
var alignment_failures: Array = []
var disjoint := true
var disjoint_seen := false
var relation_non_omitted := 0
var relation_omitted := 0
var max_lines := 2 if text_scale > 1.15 else 1
for name_value in capture_by_name.keys():
var record: Dictionary = capture_by_name[name_value]
var hud: Dictionary = record.get("hud_measurements", {})
if hud.is_empty():
continue
var nodes: Dictionary = hud.get("nodes", {})
var capture_label := String(record.get("name", name_value))
for node_name in HUD_FIXED_SLOTS.keys():
var node: Dictionary = Dictionary(nodes.get(node_name, {}))
_check_hud_node(capture_label, node, max_lines, alignment_failures)
for node_name in HUD_RULE_SUBSLOTS.keys():
var node: Dictionary = Dictionary(nodes.get(node_name, {}))
_check_hud_node(capture_label, node, max_lines, alignment_failures)
var rule: Dictionary = hud.get("rule_band", {})
if bool(rule.get("exists", false)):
disjoint_seen = true
if not bool(rule.get("disjoint", false)) or not bool(rule.get("neither_spans_band", false)):
disjoint = false
alignment_failures.append("%s rule subslots not disjoint or full-band" % capture_label)
if float(rule.get("band_union_delta_px", 99.0)) > SLOT_TOLERANCE_PX:
alignment_failures.append("%s rule union delta %.1fpx" % [capture_label, float(rule.get("band_union_delta_px", 99.0))])
var relation: Dictionary = hud.get("relation_label", {})
if bool(relation.get("visible", false)):
var placement := String(relation.get("placement_id", ""))
if placement.is_empty() or placement == "omitted_crowded":
relation_omitted += 1
alignment_failures.append("%s relation copy omitted (%s)" % [capture_label, placement])
else:
relation_non_omitted += 1
if float(relation.get("clearance_px", -99.0)) < RELATION_MIN_CLEARANCE_PX:
alignment_failures.append("%s relation clearance %.1fpx" % [capture_label, float(relation.get("clearance_px", -99.0))])
if reference_viewport and int(relation.get("token_px", 0)) != _shared_text_minimum():
alignment_failures.append("%s relation token %d != %d" % [capture_label, int(relation.get("token_px", 0)), _shared_text_minimum()])
if int(relation.get("lines", 0)) > max_lines:
alignment_failures.append("%s relation lines %d > %d" % [capture_label, int(relation.get("lines", 0)), max_lines])
if not bool(relation.get("center_in_corridor", false)) or not bool(relation.get("ink_inside_corridor", false)):
alignment_failures.append("%s relation copy outside the authored corridor" % capture_label)
hud_gate_cache = {
"slot_alignment": "PASS" if alignment_failures.is_empty() else "FAIL",
"slot_alignment_failures": alignment_failures,
"rule_subslots_disjoint": disjoint_seen and disjoint,
"relation_non_omitted_measurements": relation_non_omitted,
"relation_omitted_measurements": relation_omitted,
"slot_tolerance_px": SLOT_TOLERANCE_PX,
"applicable_token_px": _shared_text_minimum(),
"text_scale": text_scale,
"reference_viewport": reference_viewport,
"product_claim": false,
}
return hud_gate_cache
func _check_hud_node(capture_label: String, node: Dictionary, max_lines: int, alignment_failures: Array) -> void:
var node_name := String(node.get("node", ""))
if not bool(node.get("exists", false)):
alignment_failures.append("%s %s missing" % [capture_label, node_name])
return
if float(node.get("slot_delta_px", 99.0)) > SLOT_TOLERANCE_PX:
alignment_failures.append("%s %s slot delta %.1fpx" % [capture_label, node_name, float(node.get("slot_delta_px", 99.0))])
if reference_viewport and int(node.get("font_size_px", 0)) < _shared_text_minimum():
alignment_failures.append("%s %s font %dpx below token" % [capture_label, node_name, int(node.get("font_size_px", 0))])
if bool(node.get("ellipsized", false)):
alignment_failures.append("%s %s ellipsized" % [capture_label, node_name])
if int(node.get("lines_used", 0)) > max_lines:
alignment_failures.append("%s %s lines %d > %d" % [capture_label, node_name, int(node.get("lines_used", 0)), max_lines])
if bool(node.get("visible", false)):
if not bool(node.get("ink_inside_rect", false)):
alignment_failures.append("%s %s ink outside owner rect" % [capture_label, node_name])
if float(node.get("ink_margin_ratio_x", -1.0)) < 0.06 or float(node.get("ink_margin_ratio_y", -1.0)) < 0.06:
alignment_failures.append("%s %s ink margin below 6%%" % [capture_label, node_name])
if not bool(node.get("longest_shipped_ink_inside_rect", false)):
alignment_failures.append("%s %s longest shipped string does not fit" % [capture_label, node_name])
if float(node.get("longest_shipped_margin_ratio_x", -1.0)) < 0.06 or float(node.get("longest_shipped_margin_ratio_y", -1.0)) < 0.06:
alignment_failures.append("%s %s longest shipped string margin below 6%%" % [capture_label, node_name])
func _relation_path_summary() -> Dictionary:
if not relation_summary_cache.is_empty():
return relation_summary_cache
var relation_events := 0
var bridge_events := 0
var fallback_uses := 0
var proof_present := false
var measured_states := 0
for name_value in capture_by_name.keys():
var record: Dictionary = capture_by_name[name_value]
var fields: Dictionary = record.get("ownership", {})
var kind := String(record.get("event_kind", ""))
var source_id := String(record.get("source_id", ""))
var target_id := String(record.get("target_id", ""))
var source_side := String(fields.get("art.relation_source_side", ""))
var target_side := String(fields.get("art.relation_target_side", ""))
var source_anchor: Array = fields.get("art.relation_source_anchor_px", [])
var target_anchor: Array = fields.get("art.relation_target_anchor_px", [])
if not kind.is_empty():
measured_states += 1
if not source_side.is_empty() and not target_side.is_empty():
relation_events += 1
if source_id.begins_with("ally-") or source_id.begins_with("enemy-") or target_id.begins_with("ally-") or target_id.begins_with("enemy-"):
bridge_events += 1
if not source_id.is_empty() and source_anchor.size() == 2 and absf(float(source_anchor[0]) - RELATION_FALLBACK_SOURCE_PX.x) <= 0.5 and absf(float(source_anchor[1]) - RELATION_FALLBACK_SOURCE_PX.y) <= 0.5:
fallback_uses += 1
if not target_id.is_empty() and target_anchor.size() == 2 and absf(float(target_anchor[0]) - RELATION_FALLBACK_TARGET_PX.x) <= 0.5 and absf(float(target_anchor[1]) - RELATION_FALLBACK_TARGET_PX.y) <= 0.5:
fallback_uses += 1
if name_value == RESOLVED_DELTA_NAME and not source_side.is_empty() and not target_side.is_empty():
proof_present = true
relation_summary_cache = {
"status": "PRESENT" if relation_events > 0 else "NOT_PROVEN",
"relation_events": relation_events,
"bridge_relation_events": bridge_events,
"fallback_anchor_uses": fallback_uses,
"proof_present": proof_present,
"measured_states": measured_states,
"fallback_source_anchor_px": [RELATION_FALLBACK_SOURCE_PX.x, RELATION_FALLBACK_SOURCE_PX.y],
"fallback_target_anchor_px": [RELATION_FALLBACK_TARGET_PX.x, RELATION_FALLBACK_TARGET_PX.y],
}
return relation_summary_cache
func _hud_legibility_record() -> Dictionary:
var reference_capture := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
if reference_capture.is_empty():
reference_capture = Dictionary(capture_by_name.get("05-resolved", {}))
var measurements: Dictionary = reference_capture.get("hud_measurements", {})
return {
"gate": _hud_gate_summary(),
"measured_nodes": measurements.get("nodes", {}),
"rule_band": measurements.get("rule_band", {}),
"relation_label": measurements.get("relation_label", {}),
"reference_capture": String(reference_capture.get("name", "")),
"longest_shipped_source": "runtime/tests/dev5/battle_live_capture_test.gd LONGEST_SHIPPED",
}
func _path_contrast_record() -> Dictionary:
var per_capture: Array = []
var worst := 999.0
var applicable := 0
for name_value in capture_by_name.keys():
var record: Dictionary = capture_by_name[name_value]
var contrast: Dictionary = record.get("path_contrast", {})
var status := String(contrast.get("status", ""))
if contrast.is_empty() or status == "NOT_APPLICABLE" or status == "NOT_PROVEN":
continue
applicable += 1
per_capture.append({
"capture": String(record.get("name", name_value)),
"status": status,
"worst_ratio": float(contrast.get("worst_ratio", 0.0)),
"core_ratio": float(contrast.get("core_ratio", 0.0)),
"core_carrier_luminance": float(contrast.get("core_carrier_luminance", -1.0)),
"source_marker_ratio": float(contrast.get("source_marker_ratio", 0.0)),
"target_marker_ratio": float(contrast.get("target_marker_ratio", 0.0)),
"points_skipped": int(contrast.get("core_points_skipped", 0)),
"stroke": String(contrast.get("stroke", "")),
})
worst = minf(worst, float(contrast.get("worst_ratio", 0.0)))
if per_capture.is_empty():
return {
"status": "NOT_PROVEN",
"reason": "no captured state renders the relation path with a visible art node",
"contrast_min": CONTRAST_MIN,
"method": _path_contrast_method(),
}
return {
"status": "MET" if worst >= CONTRAST_MIN else "NOT_MET",
"worst_ratio": snappedf(worst, 0.01),
"contrast_min": CONTRAST_MIN,
"applicable_captures": applicable,
"method": _path_contrast_method(),
"per_capture": per_capture,
}
func _path_contrast_method() -> String:
return "M110 A5 local carrier: walk outward from the bright core (1.6-5.0px), first non-chrome pixel = carrier (UI-ink rects and palette-blend chrome with t>=0.15 excluded; actor-portrait pixels only as recorded fallback); per-point min gate; 1px annulus outside each bright ring; dynamic-neutral NOT_APPLICABLE when the art node is hidden; CONTRAST_MIN=%.1f" % CONTRAST_MIN
func _event_color_for(kind: String) -> Color:
match kind:
"healing": return Color("#70c59b", 0.96)
"guard", "guarded_recovery": return Color("#73b8dc", 0.96)
"magic": return Color("#b58bd6", 0.96)
"knockout": return Color("#e16c67", 0.98)
"result": return Color("#dfbd68", 0.98)
return Color("#e2b75f", 0.96)
func _luminance(color: Color) -> float:
var linear: Array = []
for value in [color.r, color.g, color.b]:
linear.append(value / 12.92 if value <= 0.03928 else pow((value + 0.055) / 1.055, 2.4))
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
func _contrast_ratio(left: Color, right: Color) -> float:
var lighter := maxf(_luminance(left), _luminance(right))
var darker := minf(_luminance(left), _luminance(right))
return (lighter + 0.05) / (darker + 0.05)
func _carrier_p95(samples: Array) -> Color:
if samples.is_empty():
return Color.BLACK
samples.sort_custom(func(a, b): return _luminance(a) < _luminance(b))
return samples[mini(samples.size() - 1, int(float(samples.size() - 1) * 0.95))]
func _median_color(samples: Array) -> Color:
if samples.is_empty():
return Color.BLACK
var sorted := samples.duplicate()
sorted.sort_custom(func(a, b): return _luminance(a) < _luminance(b))
return sorted[sorted.size() / 2]
func _visible_ui_ink_rects(shell: Control) -> Array:
var rects: Array = []
var nodes: Array = []
_visible_text_nodes(shell, nodes)
for node in nodes:
rects.append((node as Control).get_global_rect().grow(1.0))
return rects
func _visible_actor_rects(shell: Control) -> Array:
var rects: Array = []
for actor_group in ["BattleActors", "BattleEnemies"]:
var group := shell.find_child(actor_group, true, false)
if group == null:
continue
for actor in group.get_children():
rects.append((actor as Control).get_global_rect().grow(1.0))
return rects
func _visible_text_nodes(node: Node, out: Array) -> void:
for child in node.get_children():
if (child is Label or child is BaseButton) and (child as CanvasItem).is_visible_in_tree():
out.append(child)
_visible_text_nodes(child, out)
func _point_in_any_rect(rects: Array, point: Vector2) -> bool:
for rect_value in rects:
if (rect_value as Rect2).has_point(point):
return true
return false
func _point_near_polyline(point: Vector2, polyline: Array, radius: float) -> bool:
for index in range(polyline.size() - 1):
if _distance_to_segment(point, polyline[index], polyline[index + 1]) <= radius:
return true
return false
func _distance_to_polyline(point: Vector2, polyline: Array) -> float:
var best := 1.0e9
for index in range(polyline.size() - 1):
best = minf(best, _distance_to_segment(point, polyline[index], polyline[index + 1]))
return best
func _distance_to_segment(point: Vector2, a: Vector2, b: Vector2) -> float:
var ab := b - a
var length_squared := ab.length_squared()
if length_squared <= 0.0001:
return point.distance_to(a)
var t := clampf((point - a).dot(ab) / length_squared, 0.0, 1.0)
return point.distance_to(a + ab * t)
func _is_palette_blend(color: Color, stroke: Color, underlays: Array) -> bool:
# A pixel is chrome when it lies on the underlay -> event-hue blend line at
# t >= 0.15 (the core, echoes, arrowheads, rings and flight marks are all
# drawn in that family). Pure underlay pixels (t < 0.15) stay valid carriers.
for underlay_value in underlays:
var underlay: Color = underlay_value
var direction := Vector3(stroke.r - underlay.r, stroke.g - underlay.g, stroke.b - underlay.b)
var to_color := Vector3(color.r - underlay.r, color.g - underlay.g, color.b - underlay.b)
var length_squared := direction.length_squared()
var t := 0.0 if length_squared <= 0.000001 else clampf(direction.dot(to_color) / length_squared, 0.0, 1.0)
if t < 0.15:
continue
var closest := underlay + Color(direction.x * t, direction.y * t, direction.z * t)
var distance := absf(color.r - closest.r) + absf(color.g - closest.g) + absf(color.b - closest.b)
if distance < 0.18:
return true
return false
func _core_carrier_at(image: Image, point: Vector2, normal: Vector2, side: float, stroke: Color, underlays: Array, ui_rects: Array, actor_rects: Array) -> Dictionary:
# Walk outward from the bright core and take the first non-chrome pixel:
# with the A5 underlay in place this is the underlay; without it, the raw
# rendered carrier is returned (so the measurement can still fail honestly).
# A first valid pixel inside an actor portrait is a sprite occluder, not a
# carrier: the point is reported as occluded and excluded (M110 A5 rule).
for step in range(7):
var distance := 1.6 + 0.5 * float(step)
var probe := point + normal * distance * side
var px := int(round(probe.x))
var py := int(round(probe.y))
if px < 0 or py < 0 or px >= image.get_width() or py >= image.get_height():
continue
var pixel_position := Vector2(px, py)
if _point_in_any_rect(ui_rects, pixel_position):
continue
var color := image.get_pixel(px, py)
if _is_palette_blend(color, stroke, underlays):
continue
return {
"color": color,
"distance": distance,
"occluded": _point_in_any_rect(actor_rects, pixel_position),
}
return {}
func _ring_carrier_ratio(image: Image, center: Vector2, radius: float, stroke: Color, underlays: Array, ui_rects: Array) -> float:
var samples: Array = []
for step in range(24):
var angle := TAU * float(step) / 24.0
var direction := Vector2(cos(angle), sin(angle))
for offset in [1.4, 2.0]:
var probe: Vector2 = center + direction * (radius + float(offset))
var px := int(round(probe.x))
var py := int(round(probe.y))
if px < 0 or py < 0 or px >= image.get_width() or py >= image.get_height():
continue
if _point_in_any_rect(ui_rects, Vector2(px, py)):
continue
var color := image.get_pixel(px, py)
if _is_palette_blend(color, stroke, underlays):
continue
samples.append(color)
if samples.is_empty():
return 0.0
return _contrast_ratio(stroke, _median_color(samples))
func _path_contrast(image: Image, shell: Control) -> Dictionary:
var art := shell.find_child("BattleLiveArt", true, false) as CanvasItem
if art == null or image.is_empty():
return {"status": "NOT_PROVEN", "reason": "no art node or image"}
if not art.is_visible_in_tree():
return {"status": "NOT_APPLICABLE", "reason": "art node hidden in this view (no rendered path)"}
var kind := String(art.get("event_kind"))
if kind.is_empty() or String(art.get("source_side")).is_empty() or String(art.get("target_side")).is_empty():
return {"status": "NOT_APPLICABLE", "reason": "no relation path drawn in this state"}
var stroke := _event_color_for(kind)
var underlays: Array = [Color("#101b2a"), Color("#071015")]
var width := float(image.get_width())
var height := float(image.get_height())
var source: Vector2 = art.get("source_anchor") * Vector2(width, height)
var target: Vector2 = art.get("target_anchor") * Vector2(width, height)
var bend := -height * 0.095 if source.x < target.x else height * 0.075
var midpoint := (source + target) * 0.5 + Vector2(0.0, bend)
var points: Array = []
for index in range(21):
var t := float(index) / 20.0
points.append(source * pow(1.0 - t, 2.0) + midpoint * 2.0 * (1.0 - t) * t + target * pow(t, 2.0))
var ui_rects := _visible_ui_ink_rects(shell)
var actor_rects := _visible_actor_rects(shell)
var measured: Array = []
var skipped := 0
var occluded := 0
for index in range(2, 19):
var point: Vector2 = points[index]
var tangent: Vector2 = (Vector2(points[index + 1]) - Vector2(points[index - 1])).normalized()
var normal := Vector2(-tangent.y, tangent.x)
var point_carriers: Array = []
for side in [-1.0, 1.0]:
var entry := _core_carrier_at(image, point, normal, side, stroke, underlays, ui_rects, actor_rects)
if entry.is_empty():
continue
if bool(entry.get("occluded", false)):
occluded += 1
continue
point_carriers.append(entry)
if point_carriers.is_empty():
skipped += 1
continue
var worst_entry: Dictionary = point_carriers[0]
for entry_value in point_carriers:
var entry: Dictionary = entry_value
if _luminance(entry.color) > _luminance(worst_entry.color):
worst_entry = entry
measured.append({
"ratio": _contrast_ratio(stroke, worst_entry.color),
"carrier": worst_entry.color,
"distance": float(worst_entry.distance),
})
if measured.size() < 8:
return {
"status": "NOT_PROVEN",
"reason": "only %d of 17 core points measurable (occluded/skipped=%d)" % [measured.size(), skipped],
"stroke": stroke.to_html(false),
"core_points_measured": measured.size(),
"core_points_skipped": skipped,
"occluded": occluded,
}
measured.sort_custom(func(a, b): return float(a.ratio) < float(b.ratio))
var worst: Dictionary = measured[0]
var core_ratio := float(worst.ratio)
var core_carrier: Color = worst.carrier
var core_median := float(measured[measured.size() / 2].ratio)
var source_ratio := _ring_carrier_ratio(image, source, width * 0.012, stroke, underlays, ui_rects, actor_rects)
var target_ratio := _ring_carrier_ratio(image, target, width * 0.029, stroke, underlays, ui_rects, actor_rects)
var worst_ratio := minf(core_ratio, minf(source_ratio, target_ratio))
return {
"method": _path_contrast_method(),
"event_kind": kind,
"stroke": stroke.to_html(false),
"core_ratio": snappedf(core_ratio, 0.01),
"core_median_ratio": snappedf(core_median, 0.01),
"core_carrier": core_carrier.to_html(false),
"core_carrier_luminance": snappedf(_luminance(core_carrier), 0.0001),
"core_carrier_distance_px": snappedf(float(worst.distance), 0.1),
"core_points_measured": measured.size(),
"core_points_skipped": skipped,
"core_points_occluded": occluded,
"source_marker_ratio": snappedf(source_ratio, 0.01),
"target_marker_ratio": snappedf(target_ratio, 0.01),
"worst_ratio": snappedf(worst_ratio, 0.01),
"contrast_min": CONTRAST_MIN,
"status": "MET" if worst_ratio >= CONTRAST_MIN else "NOT_MET",
}
func _ownership_fields(shell: Control) -> Dictionary:
# M109-I02: measured node facts only. No self-authored ownership strings.
var fields := {}
_measure_label(shell.find_child("BattleLiveEventState", true, false) as Label, "state_word", fields)
_measure_label(shell.find_child("BattleLiveEventId", true, false) as Label, "event_id", fields)
_measure_label(shell.find_child("BattleLiveEventDelta", true, false) as Label, "delta", fields)
_measure_label(shell.find_child("BattleLiveEventDetail", true, false) as Label, "detail", fields)
_measure_label(shell.find_child("BattleLiveRecentEvents", true, false) as Label, "recent", fields)
var current := shell.find_child("BattleLiveCurrentEvent", true, false) as Label
if current == null:
current = shell.find_child("BattleWaitingForNextEvent", true, false) as Label
_measure_label(current, "current_event", fields)
var art := shell.find_child("BattleLiveArt", true, false)
if art == null:
fields["art.exists"] = false
fields["art.measurement_status"] = "NOT_PROVEN"
else:
fields["art.exists"] = true
fields["art.live_state"] = String(art.get("live_state"))
fields["art.event_kind"] = String(art.get("event_kind"))
fields["art.relation_source_side"] = String(art.get("source_side"))
fields["art.relation_target_side"] = String(art.get("target_side"))
var source_anchor: Vector2 = art.get("source_anchor")
var target_anchor: Vector2 = art.get("target_anchor")
fields["art.relation_source_anchor_px"] = [_rounded(source_anchor.x * viewport_size.x), _rounded(source_anchor.y * viewport_size.y)]
fields["art.relation_target_anchor_px"] = [_rounded(target_anchor.x * viewport_size.x), _rounded(target_anchor.y * viewport_size.y)]
fields["art.party_count"] = int(art.get("party_count"))
fields["art.enemy_count"] = int(art.get("enemy_count"))
var actors := shell.find_child("BattleActors", true, false)
var enemies := shell.find_child("BattleEnemies", true, false)
fields["actors.count"] = actors.get_child_count() if actors != null else -1
fields["enemies.count"] = enemies.get_child_count() if enemies != null else -1
var canvas := shell.find_child("BattleLiveCanvas", true, false) as Control
fields["canvas.exists"] = canvas != null
if canvas != null:
fields["canvas.rect"] = _rect_values(canvas)
var census := _visible_text_census(shell)
fields["visible_label_count"] = int(census.get("labels_visible", 0))
return fields
func _shared_text_minimum() -> int:
return int(UITokens.font_size(SHARED_TEXT_TOKEN, text_scale))
func _visible_text_census(shell: Control) -> Dictionary:
var labels: Array = []
_collect_visible_labels(shell, labels)
var minimum := _shared_text_minimum()
var entries: Array = []
var under := 0
var minimum_observed := -1
for label_value in labels:
var label := label_value as Label
var font_size := int(label.get_theme_font_size("font_size"))
var meets := font_size >= minimum
if not meets:
under += 1
if minimum_observed < 0 or font_size < minimum_observed:
minimum_observed = font_size
entries.append({
"node": String(label.name),
"parent": String(label.get_parent().name) if label.get_parent() != null else "",
"text": label.text.substr(0, 48),
"font_size": font_size,
"applicable_shared_token": SHARED_TEXT_TOKEN,
"shared_minimum": minimum,
"pass": meets,
"rect": _rect_values(label),
})
var status := "NO_VISIBLE_LABELS"
if not entries.is_empty():
if reference_viewport:
status = "PASS_MEASURED" if under == 0 else "NOT_PROVEN"
else:
status = "SMOKE_RECORDED"
return {
"reference_viewport": reference_viewport,
"shared_token": SHARED_TEXT_TOKEN,
"shared_minimum_px": minimum,
"token_rationale": "wide-slot field/HUD label; caption=8 remains reserved for narrow market-card chips",
"labels_visible": entries.size(),
"under_minimum": under,
"minimum_observed_font_size": minimum_observed,
"pass": under == 0 and not entries.is_empty(),
"status": status,
"product_claim": false,
"labels": entries,
}
func _collect_visible_labels(node: Node, out: Array) -> void:
for child in node.get_children():
if child is Label and (child as Label).is_visible_in_tree():
out.append(child)
_collect_visible_labels(child, out)
func _actor_scale_proxy(shell: Control) -> Dictionary:
# M109-I04: internal M77 route-gate proxy band, measured as portrait node
# rect height / viewport height. Never Owner-accepted scale.
var height := float(maxi(1, viewport_size.y))
var allies: Array = []
var enemies: Array = []
var actors := shell.find_child("BattleActors", true, false)
if actors != null:
for child in actors.get_children():
var portrait := child.find_child("BattlePortrait", true, false) as TextureRect
allies.append(_portrait_proxy_entry(child, portrait, PROXY_BAND_ALLY, height))
var enemies_node := shell.find_child("BattleEnemies", true, false)
if enemies_node != null:
for child in enemies_node.get_children():
var portrait := child.find_child("BattleEnemyPortrait", true, false) as TextureRect
enemies.append(_portrait_proxy_entry(child, portrait, PROXY_BAND_ENEMY, height))
var measured := 0
for entry_value in allies + enemies:
if bool(Dictionary(entry_value).get("measured", false)):
measured += 1
return {
"measurement": "portrait node rect height / viewport height (M77 route-gate proxy definition)",
"owner_accepted_scale": false,
"note": "internal proxy band only; Owner real-frame scale acceptance remains OPEN (B1/M109-I04)",
"internal_proxy_band": {
"ally": PROXY_BAND_ALLY,
"enemy": PROXY_BAND_ENEMY,
},
"ally_portrait_height_fraction_proxy": _fractions_of(allies),
"ally_proxy_pass": _proxy_pass_of(allies),
"enemy_portrait_height_fraction_proxy": _fractions_of(enemies),
"enemy_proxy_pass": _proxy_pass_of(enemies),
"units_measured": measured,
"status": "PROXY_MEASURED" if measured > 0 else "NOT_PROVEN",
"allies": allies,
"enemies": enemies,
}
func _portrait_proxy_entry(actor: Node, portrait: TextureRect, band: Array, height: float) -> Dictionary:
if portrait == null:
return {
"node": String(actor.name),
"measured": false,
"status": "NOT_PROVEN",
"reason": "portrait node missing",
}
var fraction := portrait.get_global_rect().size.y / height
return {
"node": String(actor.name),
"measured": true,
"portrait_height_fraction_proxy": _rounded_fraction(fraction),
"internal_proxy_band": band,
"proxy_pass": fraction >= float(band[0]) and fraction <= float(band[1]),
}
func _fractions_of(entries: Array) -> Array:
var fractions: Array = []
for entry_value in entries:
var entry: Dictionary = entry_value
if bool(entry.get("measured", false)):
fractions.append(entry.get("portrait_height_fraction_proxy", -1.0))
return fractions
func _proxy_pass_of(entries: Array) -> bool:
var any_measured := false
for entry_value in entries:
var entry: Dictionary = entry_value
if not bool(entry.get("measured", false)):
continue
any_measured = true
if not bool(entry.get("proxy_pass", false)):
return false
return any_measured
func _text_minimum_summary() -> Dictionary:
var minimum := _shared_text_minimum()
var measured_states := 0
var under_states := 0
var minimum_observed := -1
for name_value in capture_by_name.keys():
var record: Dictionary = capture_by_name[name_value]
var census: Dictionary = record.get("visible_text_census", {})
if census.is_empty() or int(census.get("labels_visible", 0)) == 0:
continue
measured_states += 1
var observed := int(census.get("minimum_observed_font_size", -1))
if observed > 0 and (minimum_observed < 0 or observed < minimum_observed):
minimum_observed = observed
if int(census.get("under_minimum", 0)) > 0:
under_states += 1
if measured_states == 0:
return {"status": "NOT_PROVEN", "reason": "no visible text was measured", "product_claim": false}
if not reference_viewport:
return {
"status": "SMOKE_RECORDED",
"reference_viewport": false,
"shared_token": SHARED_TEXT_TOKEN,
"text_scale": text_scale,
"shared_minimum_px": minimum,
"minimum_observed_font_size": minimum_observed,
"captured_states": measured_states,
"states_with_under_minimum": under_states,
"product_claim": false,
"note": "smoke viewport; no product-level text gate is claimed (M108 C1)",
}
return {
"status": "PASS_MEASURED" if under_states == 0 else "NOT_PROVEN",
"reference_viewport": true,
"shared_token": SHARED_TEXT_TOKEN,
"text_scale": text_scale,
"shared_minimum_px": minimum,
"minimum_observed_font_size": minimum_observed,
"captured_states": measured_states,
"states_with_under_minimum": under_states,
"product_claim": false,
"note": "visible type below the applicable shared token minimum is a product-level finding, not a harness failure and not a product PASS",
}
func _actor_scale_summary() -> Dictionary:
var ally_pass := true
var enemy_pass := true
var ally_fractions: Array = []
var enemy_fractions: Array = []
var any := false
for name_value in capture_by_name.keys():
var record: Dictionary = capture_by_name[name_value]
var proxy: Dictionary = record.get("actor_scale_proxy", {})
if proxy.is_empty() or int(proxy.get("units_measured", 0)) == 0:
continue
any = true
ally_pass = ally_pass and bool(proxy.get("ally_proxy_pass", false))
enemy_pass = enemy_pass and bool(proxy.get("enemy_proxy_pass", false))
if ally_fractions.is_empty():
ally_fractions = proxy.get("ally_portrait_height_fraction_proxy", [])
if enemy_fractions.is_empty():
enemy_fractions = proxy.get("enemy_portrait_height_fraction_proxy", [])
if not any:
return {"status": "NOT_PROVEN", "reason": "no actor portrait proxy was measured", "product_claim": false}
return {
"status": "PROXY_MEASURED",
"owner_accepted_scale": false,
"note": "internal proxy band only; not Owner-accepted scale (B1/M109-I04)",
"ally_portrait_height_fraction_proxy": ally_fractions,
"ally_proxy_pass": ally_pass,
"enemy_portrait_height_fraction_proxy": enemy_fractions,
"enemy_proxy_pass": enemy_pass,
"all_proxy_pass": ally_pass and enemy_pass,
"product_claim": false,
}
func _product_findings() -> Array:
var text_summary := _text_minimum_summary()
var scale_summary := _actor_scale_summary()
var proof := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
var relation: Dictionary = Dictionary(proof.get("ownership", {}))
return [
{
"id": "M109-I01",
"topic": "resolved delta proof",
"status": _delta_proof_status(),
"detail": "proof view %s: event=%s id=%s phase=%d amount=%d direction=%s delta=%s" % [
RESOLVED_DELTA_NAME,
String(proof.get("event_kind", "")),
String(proof.get("event_id", "")),
int(proof.get("phase", -1)),
int(proof.get("amount", 0)),
String(proof.get("state_delta_direction", "none")),
String(proof.get("delta_text", "")),
],
},
{
"id": "M109-I02",
"topic": "self-authored ownership claims",
"status": "REPLACED_BY_MEASUREMENTS",
"detail": "self-authored ownership lists removed; ownership changes derive from measured node visibility/text/bounds, relation endpoints and PNG hashes",
},
{
"id": "M109-I03",
"topic": "reference-viewport text minimum",
"status": String(text_summary.get("status", "NOT_PROVEN")),
"product_claim": false,
"detail": "shared token %s=%d, minimum observed %d, states under minimum %d; a miss is a product finding and is never repaired in this packet" % [
SHARED_TEXT_TOKEN,
int(text_summary.get("shared_minimum_px", _shared_text_minimum())),
int(text_summary.get("minimum_observed_font_size", -1)),
int(text_summary.get("states_with_under_minimum", 0)),
],
},
{
"id": "M109-I04",
"topic": "actor-height proxy band",
"status": String(scale_summary.get("status", "NOT_PROVEN")),
"owner_accepted_scale": false,
"detail": "portrait node height / viewport height proxy only; Owner real-frame scale acceptance remains OPEN",
},
{
"id": "M109-I05",
"topic": "Owner comprehension, feel, listening, device and final-art",
"status": "OPEN",
"detail": "Owner-only decisions; this packet cannot close them",
},
{
"id": "relation-path-measurement",
"topic": "resolved-delta relation path",
"status": "PRESENT" if not String(relation.get("art.relation_source_side", "")).is_empty() and not String(relation.get("art.relation_target_side", "")).is_empty() else "NOT_PROVEN",
"detail": "BattleLiveArt source_side=%s target_side=%s for the non-zero proof event; an empty measurement is recorded as NOT_PROVEN and is not repaired in this packet" % [
String(relation.get("art.relation_source_side", "")),
String(relation.get("art.relation_target_side", "")),
],
},
]
func _matrix_state_for(engine_state: String) -> String:
match engine_state:
"waiting_for_next_event", "event_forecast", "action_committed", "event_resolving", "event_resolved":
return engine_state
return ""
func _capture_static_only(viewport: SubViewport, shell: Control) -> void:
# M107-P2: authored/static ownership evidence. World, actors and rails stay;
# engine text and command controls hide through existing node visibility.
var hidden: Array[Node] = []
_set_engine_text_visible(shell, false, hidden)
_expect(not hidden.is_empty(), "static-only isolates at least one engine text or control node")
await _frames(2)
await _capture(viewport, shell, "static-only", "view", {
"hidden_kinds": ["Label", "BaseButton"],
"hidden_nodes": hidden.size(),
"kept": ["BattleLiveWorld", "BattleLiveArt", "BattleActors", "BattleEnemies", "rails", "actor_bars"],
})
_set_visible(hidden, true)
await _frames(1)
var restored := true
for node in hidden:
if is_instance_valid(node) and not (node as CanvasItem).visible:
restored = false
_expect(restored, "static-only capture restores every hidden node")
Dictionary(capture_by_name.get("static-only", {})).set("restored", restored)
func _capture_dynamic_neutral(viewport: SubViewport, shell: Control) -> void:
# M107-P2: dynamic state on a neutral surface. Scene art hides through
# existing node visibility; the neutral plate is a harness-only ColorRect and
# is removed before the next capture. No runtime chrome is added.
var hidden: Array[Node] = []
for node_name in ["BattleLiveWorld", "BattleLiveArt", "BattleActors", "BattleEnemies"]:
var node := shell.find_child(node_name, true, false) as CanvasItem
if node != null and node.visible:
node.visible = false
hidden.append(node)
var neutral := ColorRect.new()
neutral.name = "M107CaptureNeutralField"
neutral.color = Color("#5b6165")
neutral.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
neutral.mouse_filter = Control.MOUSE_FILTER_IGNORE
shell.add_child(neutral)
shell.move_child(neutral, 0)
await _frames(2)
await _capture(viewport, shell, "dynamic-neutral", "view", {
"hidden_nodes": hidden.size(),
"hidden": ["BattleLiveWorld", "BattleLiveArt", "BattleActors", "BattleEnemies"],
"neutral": "#5b6165 harness ColorRect, removed after capture",
})
_expect(hidden.size() == 4, "dynamic-neutral isolates world, field art, actors and enemies")
neutral.queue_free()
_set_visible(hidden, true)
await _frames(2)
var restored := true
for node in hidden:
if is_instance_valid(node) and not (node as CanvasItem).visible:
restored = false
_expect(restored and not is_instance_valid(neutral), "dynamic-neutral restores the scene and removes the neutral plate")
Dictionary(capture_by_name.get("dynamic-neutral", {})).set("restored", restored)
Dictionary(capture_by_name.get("dynamic-neutral", {})).set("neutral_removed", not is_instance_valid(neutral))
func _set_engine_text_visible(node: Node, visible: bool, hidden: Array[Node]) -> void:
for child in node.get_children():
if child is Label or child is BaseButton:
var item := child as CanvasItem
if item.visible != visible:
item.visible = visible
if not visible:
hidden.append(child)
_set_engine_text_visible(child, visible, hidden)
func _set_visible(nodes: Array[Node], visible: bool) -> void:
for node in nodes:
if is_instance_valid(node):
(node as CanvasItem).visible = visible
func _file_sha256(path: String) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return ""
var context := HashingContext.new()
context.start(HashingContext.HASH_SHA256)
var remaining := file.get_length()
while remaining > 0:
var chunk := mini(65536, remaining)
context.update(file.get_buffer(chunk))
remaining -= chunk
file.close()
return context.finish().hex_encode()
func _verify_layout(shell: Control, state_name: String) -> void:
var canvas := shell.find_child("BattleLiveCanvas", true, false) as Control
if canvas == null:
failures.append("%s has no contained live canvas" % state_name)
return
var actors := shell.find_child("BattleActors", true, false) as Control
var enemies := shell.find_child("BattleEnemies", true, false) as Control
if actors == null or actors.get_child_count() != 4:
failures.append("%s loses the complete Party line" % state_name)
if enemies == null or enemies.get_child_count() != 3:
failures.append("%s loses the complete enemy line" % state_name)
_verify_buttons(shell, state_name)
func _verify_buttons(node: Node, state_name: String) -> void:
for child in node.get_children():
if child is BaseButton and child.is_visible_in_tree():
var rect := (child as Control).get_global_rect()
if rect.size.x < 47.9 or rect.size.y < 47.9:
failures.append("%s button %s below 48px" % [state_name, child.name])
if rect.position.x < -1.0 or rect.position.y < -1.0 or rect.end.x > viewport_size.x + 1.0 or rect.end.y > viewport_size.y + 1.0:
failures.append("%s button %s outside viewport" % [state_name, child.name])
_verify_buttons(child, state_name)
func _create_prepared_company() -> Dictionary:
var draft: Dictionary = _draft_service().begin_or_load(SEED)
if not draft.ok:
return {}
_draft_service().set_selection([String(draft.draft.offers[0].offer_id), String(draft.draft.offers[2].offer_id)])
if not _draft_service().commit_company().ok:
return {}
var created: Dictionary = _contract_service().create_contract(CONTRACT_OP, "CTR-R1-01", "standard")
if not created.ok:
return {}
var offers: Array = created.company.active_contract.market_board.recruit_offers
_contract_service().hire_recruit(HIRE_OP_1, String(offers[0].source_offer_id), 14)
var hired: Dictionary = _contract_service().hire_recruit(HIRE_OP_2, String(offers[1].source_offer_id), 14)
if not hired.ok:
return {}
var begun: Dictionary = _formation_service().begin_formation()
var saved: Dictionary = _formation_service().save_order(ORDER_OP, begun.formation.base_front_to_rear_ids)
var ready: Dictionary = _formation_service().accept_readiness(READY_OP, String(saved.receipt.order_hash))
return ready.company if ready.ok else {}
func _draft_service() -> RefCounted:
var service := DraftService.new()
service.path = DRAFT_PATH
service.company_path = COMPANY_PATH
return service
func _contract_service() -> RefCounted:
var service := ContractService.new()
service.company_path = COMPANY_PATH
return service
func _formation_service() -> RefCounted:
var service := FormationService.new()
service.company_path = COMPANY_PATH
return service
func _battle_service() -> RefCounted:
var service := BattleService.new()
service.company_path = COMPANY_PATH
return service
func _frames(count: int) -> void:
for _index in range(count):
await process_frame
func _expect(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _cleanup() -> void:
for path in [
DRAFT_PATH, "%s.bak" % DRAFT_PATH, "%s.candidate" % DRAFT_PATH,
"%s.bak.candidate" % DRAFT_PATH, COMPANY_PATH, "%s.bak" % COMPANY_PATH,
"%s.candidate" % COMPANY_PATH, "%s.bak.candidate" % COMPANY_PATH,
"%s.corrupt" % COMPANY_PATH,
]:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)