refactor(M03): extract ADT water load pipeline state

This commit is contained in:
2026-07-17 09:38:24 +04:00
parent 7973945582
commit b7c036f14d
9 changed files with 824 additions and 53 deletions
@@ -0,0 +1,167 @@
class_name AdtWaterLoadPipelineState
extends RefCounted
## Owns ADT water request, active-task and worker-result bookkeeping. The caller
## owns WorkerThreadPool operations, ADT parsing, permits and scene finalization.
var _pending_requests: Array[Dictionary] = []
var _queued_tile_keys: Dictionary = {}
var _active_task_ids_by_tile_key: Dictionary = {}
var _result_mutex := Mutex.new()
var _results: Array[Dictionary] = []
## Appends one tile/path request in FIFO order. Invalid, queued or active tile
## keys are rejected without mutation.
func enqueue_request(tile_key: String, adt_path: String) -> bool:
if tile_key.is_empty() or adt_path.is_empty():
return false
if _queued_tile_keys.has(tile_key) or _active_task_ids_by_tile_key.has(tile_key):
return false
_pending_requests.append({
"key": tile_key,
"path": adt_path,
})
_queued_tile_keys[tile_key] = true
return true
## Removes and returns the oldest request, also releasing its queued-key marker.
## An empty Dictionary means no pending request exists.
func take_next_request() -> Dictionary:
if _pending_requests.is_empty():
return {}
var request: Dictionary = _pending_requests.pop_front()
_queued_tile_keys.erase(String(request.get("key", "")))
return request
## Removes all pending requests for one tile without affecting active work.
## Returns the number of removed FIFO entries.
func cancel_pending_requests(tile_key: String) -> int:
if tile_key.is_empty():
return 0
_queued_tile_keys.erase(tile_key)
var removed_count := 0
for request_index in range(_pending_requests.size() - 1, -1, -1):
var request: Dictionary = _pending_requests[request_index]
if String(request.get("key", "")) != tile_key:
continue
_pending_requests.remove_at(request_index)
removed_count += 1
return removed_count
## Records one caller-started worker task. The opaque task ID is retained until
## the caller waits for and completes it.
func remember_active_task(tile_key: String, task_id: int) -> bool:
if tile_key.is_empty() or _active_task_ids_by_tile_key.has(tile_key):
return false
_active_task_ids_by_tile_key[tile_key] = task_id
return true
## Returns whether a worker task is active for the tile.
func has_active_task(tile_key: String) -> bool:
return not tile_key.is_empty() and _active_task_ids_by_tile_key.has(tile_key)
## Returns the opaque active task ID, or -1 for an unknown tile.
func active_task_id_for(tile_key: String) -> int:
return int(_active_task_ids_by_tile_key.get(tile_key, -1))
## Removes and returns one completed task ID, or -1 for an unknown tile.
func complete_active_task(tile_key: String) -> int:
if tile_key.is_empty() or not _active_task_ids_by_tile_key.has(tile_key):
return -1
var task_id := int(_active_task_ids_by_tile_key[tile_key])
_active_task_ids_by_tile_key.erase(tile_key)
return task_id
## Returns a detached snapshot of opaque task IDs for orderly shutdown waiting.
func active_task_ids_snapshot() -> Array:
return _active_task_ids_by_tile_key.values().duplicate()
## Publishes one parsed ADT result from a worker thread. Payload identity is
## retained; the caller must not mutate it after publication.
func publish_result(tile_key: String, adt_path: String, water_data: Dictionary) -> bool:
if tile_key.is_empty() or adt_path.is_empty():
return false
_result_mutex.lock()
_results.append({
"key": tile_key,
"path": adt_path,
"data": water_data,
})
_result_mutex.unlock()
return true
## Returns whether a worker result is currently ready.
func has_result() -> bool:
_result_mutex.lock()
var result_available := not _results.is_empty()
_result_mutex.unlock()
return result_available
## Removes and returns the oldest worker result. An empty Dictionary means no
## result was ready. Intended for the serialized renderer main thread.
func pop_result() -> Dictionary:
_result_mutex.lock()
var result: Dictionary = {}
if not _results.is_empty():
result = _results.pop_front()
_result_mutex.unlock()
return result
## Returns pending plus active load count for existing renderer metrics.
func total_load_count() -> int:
return _pending_requests.size() + _active_task_ids_by_tile_key.size()
## Returns the pending request count.
func pending_request_count() -> int:
return _pending_requests.size()
## Returns the active worker-task count.
func active_task_count() -> int:
return _active_task_ids_by_tile_key.size()
## Clears bookkeeping and queued results without interrupting worker tasks.
## Orderly shutdown must wait the task-ID snapshot before calling this method.
func clear() -> void:
_pending_requests.clear()
_queued_tile_keys.clear()
_active_task_ids_by_tile_key.clear()
_result_mutex.lock()
_results.clear()
_result_mutex.unlock()
## Returns detached FIFO/task/result scalar diagnostics without water payloads.
func diagnostic_snapshot() -> Dictionary:
var pending_requests: Array[Dictionary] = []
for request in _pending_requests:
pending_requests.append(request.duplicate())
var active_tile_keys: Array = _active_task_ids_by_tile_key.keys()
active_tile_keys.sort()
_result_mutex.lock()
var result_records: Array[Dictionary] = []
for result in _results:
result_records.append({
"key": String(result.get("key", "")),
"path": String(result.get("path", "")),
})
_result_mutex.unlock()
return {
"pending_requests": pending_requests,
"active_tile_keys": active_tile_keys.duplicate(),
"result_records": result_records,
}
@@ -0,0 +1 @@
uid://pwqm3n25pnn7
+27 -52
View File
@@ -42,6 +42,9 @@ const TERRAIN_CHUNK_LOD_PLANNER_SCRIPT := preload("res://src/render/terrain/terr
const TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER_SCRIPT := preload(
"res://src/render/terrain/terrain_chunk_geometry_queue_planner.gd"
)
const ADT_WATER_LOAD_PIPELINE_STATE_SCRIPT := preload(
"res://src/render/liquid/adt_water_load_pipeline_state.gd"
)
const M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT := preload(
"res://src/render/m2/m2_unique_placement_registry.gd"
)
@@ -256,11 +259,7 @@ var _terrain_splat_cache_tasks: Dictionary = {}
var _terrain_splat_tasks: Dictionary = {}
var _terrain_splat_result_mutex := Mutex.new()
var _terrain_splat_result_queue: Array = []
var _water_load_queue: Array = []
var _water_load_queued: Dictionary = {}
var _water_load_tasks: Dictionary = {}
var _water_result_mutex := Mutex.new()
var _water_result_queue: Array = []
var _adt_water_load_pipeline_state := ADT_WATER_LOAD_PIPELINE_STATE_SCRIPT.new()
var _tile_result_mutex := Mutex.new()
var _tile_result_queue: Array = []
var _shared_tex_cache: Dictionary = {}
@@ -999,7 +998,7 @@ func _log_hitch_profile(start_usec: int, timings: Array[String], did_refresh: bo
_terrain_upgrade_tasks.size(),
_terrain_control_splat_cache_tasks.size(),
_terrain_splat_queue_size(),
_water_load_queue.size() + _water_load_tasks.size(),
_adt_water_load_pipeline_state.total_load_count(),
_detail_asset_queue.size(),
_m2_group_tasks.size(),
_m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(),
@@ -1030,7 +1029,7 @@ func render_baseline_snapshot() -> Dictionary:
"terrain_upgrade": _terrain_upgrade_tasks.size(),
"terrain_control": _terrain_control_splat_cache_tasks.size(),
"terrain_splat": _terrain_splat_queue_size(),
"water": _water_load_queue.size() + _water_load_tasks.size(),
"water": _adt_water_load_pipeline_state.total_load_count(),
"detail": _detail_asset_queue.size(),
"m2_task": _m2_group_tasks.size(),
"m2_animation": _m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(),
@@ -1305,7 +1304,7 @@ func _tick_runtime_stats(delta: float) -> void:
_terrain_upgrade_tasks.size(),
_terrain_control_splat_cache_tasks.size(),
_terrain_splat_queue_size(),
_water_load_queue.size() + _water_load_tasks.size(),
_adt_water_load_pipeline_state.total_load_count(),
terrain_baked_full,
terrain_control_splat,
terrain_splat,
@@ -2230,24 +2229,20 @@ func _request_tile_water_load(state: Dictionary) -> void:
var path := String(state.get("path", ""))
if key.is_empty() or path.is_empty():
return
if _water_load_tasks.has(key) or _water_load_queued.has(key):
return
_water_load_queue.append({
"key": key,
"path": path,
})
_water_load_queued[key] = true
_adt_water_load_pipeline_state.enqueue_request(key, path)
func _process_water_load_queue() -> void:
if not enable_water:
return
var limit := maxi(1, max_concurrent_water_tasks)
while _water_load_tasks.size() < limit and not _water_load_queue.is_empty():
var request: Dictionary = _water_load_queue.pop_front()
while (
_adt_water_load_pipeline_state.active_task_count() < limit
and _adt_water_load_pipeline_state.pending_request_count() > 0
):
var request: Dictionary = _adt_water_load_pipeline_state.take_next_request()
var key := String(request.get("key", ""))
_water_load_queued.erase(key)
if key.is_empty() or _water_load_tasks.has(key):
if key.is_empty() or _adt_water_load_pipeline_state.has_active_task(key):
continue
if not _tile_states.has(key):
continue
@@ -2255,7 +2250,7 @@ func _process_water_load_queue() -> void:
if path.is_empty():
continue
var task_id: int = WorkerThreadPool.add_task(_load_tile_water_task.bind(key, path))
_water_load_tasks[key] = task_id
_adt_water_load_pipeline_state.remember_active_task(key, task_id)
func _load_tile_water_task(key: String, adt_path: String) -> void:
@@ -2264,13 +2259,7 @@ func _load_tile_water_task(key: String, adt_path: String) -> void:
if loader:
data = loader.call("load_adt", adt_path)
_water_result_mutex.lock()
_water_result_queue.append({
"key": key,
"path": adt_path,
"data": data,
})
_water_result_mutex.unlock()
_adt_water_load_pipeline_state.publish_result(key, adt_path, data)
func _drain_water_load_results() -> void:
@@ -2278,20 +2267,20 @@ func _drain_water_load_results() -> void:
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE):
return
_water_result_mutex.lock()
while not _water_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
while _adt_water_load_pipeline_state.has_result() and _render_budget_scheduler.try_consume_permit(
RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE):
results.append(_water_result_queue.pop_front())
_water_result_mutex.unlock()
results.append(_adt_water_load_pipeline_state.pop_result())
for result in results:
var key := String(result.get("key", ""))
if key.is_empty():
continue
if not _water_load_tasks.has(key):
if not _adt_water_load_pipeline_state.has_active_task(key):
continue
WorkerThreadPool.wait_for_task_completion(int(_water_load_tasks[key]))
_water_load_tasks.erase(key)
WorkerThreadPool.wait_for_task_completion(
_adt_water_load_pipeline_state.active_task_id_for(key)
)
_adt_water_load_pipeline_state.complete_active_task(key)
if _shutting_down or not _tile_states.has(key):
continue
@@ -2419,14 +2408,9 @@ func _wait_for_tile_tasks() -> void:
_terrain_splat_result_queue.clear()
_terrain_splat_result_mutex.unlock()
for task_id in _water_load_tasks.values():
for task_id in _adt_water_load_pipeline_state.active_task_ids_snapshot():
WorkerThreadPool.wait_for_task_completion(int(task_id))
_water_load_tasks.clear()
_water_load_queue.clear()
_water_load_queued.clear()
_water_result_mutex.lock()
_water_result_queue.clear()
_water_result_mutex.unlock()
_adt_water_load_pipeline_state.clear()
func _should_accept_loaded_tile(request: Dictionary) -> bool:
@@ -2961,11 +2945,7 @@ func _release_tile(key: String) -> void:
_terrain_control_splat_cache_tasks.erase(key)
_terrain_splat_cache_tasks.erase(key)
_terrain_splat_tasks.erase(key)
_water_load_queued.erase(key)
for i in range(_water_load_queue.size() - 1, -1, -1):
var pending: Dictionary = _water_load_queue[i]
if String(pending.get("key", "")) == key:
_water_load_queue.remove_at(i)
_adt_water_load_pipeline_state.cancel_pending_requests(key)
var tile_lod_rid: RID = state.get("tile_lod_rid", RID())
if tile_lod_rid.is_valid():
_free_render_instance(tile_lod_rid)
@@ -2996,12 +2976,7 @@ func _clear_streamed_world() -> void:
_terrain_splat_result_mutex.lock()
_terrain_splat_result_queue.clear()
_terrain_splat_result_mutex.unlock()
_water_load_queue.clear()
_water_load_queued.clear()
_water_load_tasks.clear()
_water_result_mutex.lock()
_water_result_queue.clear()
_water_result_mutex.unlock()
_adt_water_load_pipeline_state.clear()
_detail_asset_queue.clear()
_detail_asset_queued.clear()
for key in _m2_build_jobs.keys():
@@ -0,0 +1,351 @@
extends SceneTree
## Asset-free lifecycle, threading, boundary and timing regression for ADT water loads.
const PIPELINE_SCRIPT := preload(
"res://src/render/liquid/adt_water_load_pipeline_state.gd"
)
const PIPELINE_PATH := "res://src/render/liquid/adt_water_load_pipeline_state.gd"
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_invalid_dedupe_and_fifo(failures)
_verify_active_task_lifecycle(failures)
_verify_result_mailbox_and_payload_identity(failures)
_verify_worker_publication(failures)
_verify_pending_cancellation(failures)
_verify_clear_and_detached_diagnostics(failures)
_verify_ownership_boundaries(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
if not failures.is_empty():
for failure in failures:
push_error("ADT_WATER_LOAD_PIPELINE_STATE: %s" % failure)
quit(1)
return
print(
"ADT_WATER_LOAD_PIPELINE_STATE PASS cases=12 iterations=100 elapsed_ms=%.3f"
% elapsed_milliseconds
)
quit(0)
func _verify_invalid_dedupe_and_fifo(failures: Array[String]) -> void:
var pipeline: RefCounted = PIPELINE_SCRIPT.new()
_expect_false(
pipeline.call("enqueue_request", "", "res://tile.adt"),
"empty tile key rejected",
failures
)
_expect_false(
pipeline.call("enqueue_request", "31:49", ""),
"empty path rejected",
failures
)
_expect_true(
pipeline.call("enqueue_request", "31:49", "res://a.adt"),
"first request accepted",
failures
)
_expect_false(
pipeline.call("enqueue_request", "31:49", "res://duplicate.adt"),
"queued duplicate rejected",
failures
)
pipeline.call("enqueue_request", "32:49", "res://b.adt")
_expect_equal(int(pipeline.call("pending_request_count")), 2, "pending count", failures)
var first_request: Dictionary = pipeline.call("take_next_request")
var second_request: Dictionary = pipeline.call("take_next_request")
_expect_string_equal(String(first_request["key"]), "31:49", "FIFO first key", failures)
_expect_string_equal(String(second_request["key"]), "32:49", "FIFO second key", failures)
_expect_true(
pipeline.call("enqueue_request", "31:49", "res://retry.adt"),
"dequeued key can be requested again",
failures
)
func _verify_active_task_lifecycle(failures: Array[String]) -> void:
var pipeline: RefCounted = PIPELINE_SCRIPT.new()
_expect_false(
pipeline.call("remember_active_task", "", 7),
"empty active key rejected",
failures
)
_expect_true(
pipeline.call("remember_active_task", "31:49", 101),
"active task accepted",
failures
)
_expect_false(
pipeline.call("remember_active_task", "31:49", 202),
"duplicate active task rejected",
failures
)
_expect_true(
pipeline.call("has_active_task", "31:49"),
"active task observable",
failures
)
_expect_equal(
int(pipeline.call("active_task_id_for", "31:49")),
101,
"opaque task ID retained",
failures
)
_expect_false(
pipeline.call("enqueue_request", "31:49", "res://active.adt"),
"active tile rejects enqueue",
failures
)
_expect_equal(
int(pipeline.call("complete_active_task", "31:49")),
101,
"completion returns task ID",
failures
)
_expect_equal(
int(pipeline.call("complete_active_task", "31:49")),
-1,
"unknown completion rejected",
failures
)
func _verify_result_mailbox_and_payload_identity(failures: Array[String]) -> void:
var pipeline: RefCounted = PIPELINE_SCRIPT.new()
var water_data := {"chunks": [{"liquid": true}]}
_expect_false(
pipeline.call("publish_result", "", "res://a.adt", water_data),
"empty result key rejected",
failures
)
_expect_true(
pipeline.call("publish_result", "31:49", "res://a.adt", water_data),
"valid result published",
failures
)
_expect_true(pipeline.call("has_result"), "published result observable", failures)
var result: Dictionary = pipeline.call("pop_result")
_expect_string_equal(String(result["key"]), "31:49", "result key", failures)
_expect_string_equal(String(result["path"]), "res://a.adt", "result path", failures)
_expect_true(result["data"] == water_data, "water payload identity retained", failures)
_expect_false(pipeline.call("has_result"), "result pop empties mailbox", failures)
_expect_true(
(pipeline.call("pop_result") as Dictionary).is_empty(),
"empty result pop is safe",
failures
)
func _verify_worker_publication(failures: Array[String]) -> void:
var pipeline: RefCounted = PIPELINE_SCRIPT.new()
var worker := Thread.new()
var start_error := worker.start(
Callable(pipeline, "publish_result").bind(
"worker",
"res://worker.adt",
{"worker": true}
)
)
_expect_equal(int(start_error), OK, "worker publication starts", failures)
if start_error == OK:
worker.wait_to_finish()
_expect_true(pipeline.call("has_result"), "worker result safely visible", failures)
var result: Dictionary = pipeline.call("pop_result")
_expect_string_equal(String(result.get("key", "")), "worker", "worker result key", failures)
func _verify_pending_cancellation(failures: Array[String]) -> void:
var pipeline: RefCounted = PIPELINE_SCRIPT.new()
pipeline.call("enqueue_request", "31:49", "res://a.adt")
pipeline.call("enqueue_request", "32:49", "res://b.adt")
pipeline.call("remember_active_task", "31:49-active", 12)
_expect_equal(
int(pipeline.call("cancel_pending_requests", "31:49")),
1,
"tile cancellation removes pending request",
failures
)
_expect_equal(int(pipeline.call("pending_request_count")), 1, "other pending retained", failures)
_expect_true(
pipeline.call("has_active_task", "31:49-active"),
"pending cancellation leaves active work",
failures
)
_expect_equal(
int(pipeline.call("cancel_pending_requests", "unknown")),
0,
"unknown cancellation unchanged",
failures
)
func _verify_clear_and_detached_diagnostics(failures: Array[String]) -> void:
var pipeline: RefCounted = PIPELINE_SCRIPT.new()
pipeline.call("enqueue_request", "32:49", "res://b.adt")
pipeline.call("enqueue_request", "31:49", "res://a.adt")
pipeline.call("remember_active_task", "z", 2)
pipeline.call("remember_active_task", "a", 1)
pipeline.call("publish_result", "result", "res://result.adt", {"secret": "payload"})
var snapshot: Dictionary = pipeline.call("diagnostic_snapshot")
_expect_string_array(
snapshot["active_tile_keys"],
["a", "z"],
"active diagnostics sorted",
failures
)
_expect_equal(
(snapshot["pending_requests"] as Array).size(),
2,
"pending diagnostics count",
failures
)
_expect_false(
(snapshot["result_records"][0] as Dictionary).has("data"),
"diagnostics omit water payload",
failures
)
(snapshot["pending_requests"] as Array).clear()
var fresh_snapshot: Dictionary = pipeline.call("diagnostic_snapshot")
_expect_equal(
(fresh_snapshot["pending_requests"] as Array).size(),
2,
"diagnostics detached",
failures
)
pipeline.call("clear")
pipeline.call("clear")
_expect_equal(int(pipeline.call("total_load_count")), 0, "clear load count", failures)
_expect_false(pipeline.call("has_result"), "clear result mailbox", failures)
func _verify_ownership_boundaries(failures: Array[String]) -> void:
var loader_source := _read_text(LOADER_PATH, failures)
var pipeline_source := _read_text(PIPELINE_PATH, failures)
_expect_true(
loader_source.contains("ADT_WATER_LOAD_PIPELINE_STATE_SCRIPT.new()"),
"loader composes pipeline state",
failures
)
for legacy_field in [
"var _water_load_queue:",
"var _water_load_queued:",
"var _water_load_tasks:",
"var _water_result_mutex",
"var _water_result_queue:",
]:
_expect_false(loader_source.contains(legacy_field), "loader removes %s" % legacy_field, failures)
for retained_loader_rule in [
"ClassDB.instantiate(\"ADTLoader\")",
"WorkerThreadPool.add_task(_load_tile_water_task.bind(key, path))",
"RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE",
"_builder.build_tile_water_scene(data, origin)",
"tile_root.add_child(water_root)",
"state[\"water_loaded\"] = true",
]:
_expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures)
for forbidden_token in [
"WorkerThreadPool.",
"ADTLoader",
"RenderBudgetScheduler",
"Node3D",
"Mesh",
"RID",
"_tile_states",
]:
_expect_false(
pipeline_source.contains(forbidden_token),
"pipeline omits %s ownership" % forbidden_token,
failures
)
_expect_equal(
_count_occurrences(loader_source, "_adt_water_load_pipeline_state.clear()"),
2,
"loader retains two clear sites",
failures
)
func _verify_bounded_timing(failures: Array[String]) -> float:
var pipeline: RefCounted = PIPELINE_SCRIPT.new()
var started_microseconds := Time.get_ticks_usec()
for iteration in range(100):
for tile_index in range(256):
var tile_key := "tile:%d" % tile_index
pipeline.call("enqueue_request", tile_key, "res://tile_%d.adt" % tile_index)
while int(pipeline.call("pending_request_count")) > 0:
var request: Dictionary = pipeline.call("take_next_request")
var tile_key := String(request["key"])
pipeline.call("remember_active_task", tile_key, tile_key.hash())
pipeline.call("publish_result", tile_key, String(request["path"]), {})
while pipeline.call("has_result"):
var result: Dictionary = pipeline.call("pop_result")
pipeline.call("complete_active_task", String(result["key"]))
pipeline.call("clear")
_expect_equal(
int(pipeline.call("total_load_count")),
0,
"timing cycle %d clears" % iteration,
failures
)
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(
elapsed_milliseconds < 1000.0,
"100 request/task/result cycles remain bounded",
failures
)
return elapsed_milliseconds
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _count_occurrences(text: String, token: String) -> int:
return text.split(token).size() - 1
func _expect_equal(
actual_value: int,
expected_value: int,
label: String,
failures: Array[String]
) -> void:
if actual_value != expected_value:
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
func _expect_string_equal(
actual_value: String,
expected_value: String,
label: String,
failures: Array[String]
) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_string_array(
actual_values: Array,
expected_values: Array,
label: String,
failures: Array[String]
) -> void:
if actual_values != expected_values:
failures.append("%s expected %s, got %s" % [label, expected_values, actual_values])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
func _expect_false(actual_value: bool, label: String, failures: Array[String]) -> void:
if actual_value:
failures.append("%s expected false" % label)
@@ -0,0 +1 @@
uid://bb3v38yw47utf