Source code for mini_arcade_core.utils.profiler
"""
Game core module defining the Game class and configuration.
"""
from __future__ import annotations
import enum
import logging
from dataclasses import dataclass, field
from time import perf_counter
from typing import Dict, Iterable, Mapping
perf_logger = logging.getLogger("mini-arcade-core.perf")
[docs]
class Ansi(enum.Enum):
"""
ANSI escape codes for terminal text formatting.
cvar RESET (str): Reset all formatting.
cvar BOLD (str): Bold text.
cvar DIM (str): Dim text.
cvar RED (str): Red text.
cvar GREEN (str): Green text.
cvar YELLOW (str): Yellow text.
cvar CYAN (str): Cyan text.
cvar MAGENTA (str): Magenta text.
cvar WHITE (str): White text.
"""
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
WHITE = "\033[97m"
def _c(text: str, *codes: str) -> str:
"""Convenience function to wrap text with ANSI codes."""
return "".join(codes) + text + Ansi.RESET.value
[docs]
@dataclass(frozen=True)
class FrameTimingReport:
"""
Report of frame timing data.
:ivar frame_index (int): Index of the frame.
:ivar diffs_ms (Dict[str, float]): Dictionary of time differences in milliseconds.
:ivar total_ms (float): Total time in milliseconds.
:ivar budget_ms (float): Frame budget in milliseconds.
"""
frame_index: int
diffs_ms: Dict[str, float]
total_ms: float
budget_ms: float
[docs]
@dataclass(frozen=True)
class FrameTimingFormatter:
"""
Formats a FrameTimingReport into a colored, multi-line table string.
Keeps FrameTimer lean and avoids pylint complexity in the timer itself.
:ivar target_fps (int): Target frames per second for budget calculation.
:ivar top_n (int): Number of top time-consuming segments to display.
:ivar min_ms (float): Minimum time in milliseconds to include in the top list.
:ivar phases (tuple[tuple[str, str], ...]): Tuples of (display name, mark key)
for table columns.
"""
target_fps: int = 60
top_n: int = 6
min_ms: float = 0.05
# These are the “headline” segments you want as columns.
phases: tuple[tuple[str, str], ...] = (
("events", "frame_start->events_polled"),
("input", "events_polled->input_built"),
("tick", "tick_start->tick_end"),
("render", "render_start->render_done"),
("sleep", "sleep_start->sleep_end"),
)
[docs]
def make_report(
self, frame_index: int, diffs_ms: Dict[str, float]
) -> FrameTimingReport:
"""
Create a FrameTimingReport from the given diffs.
:param frame_index: Index of the frame.
:type frame_index: int
:param diffs_ms: Dictionary of time differences in milliseconds.
:type diffs_ms: Dict[str, float]
:return: FrameTimingReport instance.
:rtype: FrameTimingReport
"""
total = sum(diffs_ms.values()) if diffs_ms else 0.0
budget = (1000.0 / self.target_fps) if self.target_fps > 0 else 0.0
return FrameTimingReport(
frame_index=frame_index,
diffs_ms=diffs_ms,
total_ms=total,
budget_ms=budget,
)
[docs]
def format(self, report: FrameTimingReport) -> str:
"""
Format the FrameTimingReport into a colored string.
:param report: FrameTimingReport instance.
:type report: FrameTimingReport
:return: Formatted string.
:rtype: str
"""
header = self._format_header(report)
table = self._format_table(report.diffs_ms)
top = self._format_top(report.diffs_ms)
return f"{header}\n{table}\n{top}\n"
def _format_header(self, report: FrameTimingReport) -> str:
over = report.budget_ms > 0 and report.total_ms > report.budget_ms
status = (
_c("OVER", Ansi.BOLD.value, Ansi.RED.value)
if over
else _c("OK", Ansi.BOLD.value, Ansi.GREEN.value)
)
frame = _c(
f"[Frame {report.frame_index}]", Ansi.BOLD.value, Ansi.WHITE.value
)
total = _c(
f"{report.total_ms:.2f}ms", Ansi.BOLD.value, Ansi.WHITE.value
)
budget = _c(f"{report.budget_ms:.2f}ms", Ansi.DIM.value)
dim = Ansi.DIM.value
return (
f"{frame} {_c('total', dim)}={total} "
f"{_c('budget', dim)}={budget} {_c('status', dim)}={status}"
)
def _format_table(self, diffs: Mapping[str, float]) -> str:
# Header line
headers = [name for name, _ in self.phases]
line_h = self._pipe_row((_c(h, Ansi.DIM.value) for h in headers))
# Values line
values = [diffs.get(key, 0.0) for _, key in self.phases]
line_v = self._pipe_row(self._color_values(values))
return f"{line_h}\n{line_v}"
def _color_values(self, values: Iterable[float]) -> list[str]:
# Keep coloring policy centralized and easy to tweak.
# events/input: cyan, tick: yellow, render: magenta, sleep: green
colors = [
Ansi.CYAN.value,
Ansi.CYAN.value,
Ansi.YELLOW.value,
Ansi.MAGENTA.value,
Ansi.GREEN.value,
]
out: list[str] = []
for v, col in zip(values, colors):
out.append(_c(f"{v:6.2f}", col))
return out
def _format_top(self, diffs: Mapping[str, float]) -> str:
dim = Ansi.DIM.value
items = [
(k, float(v)) for k, v in diffs.items() if float(v) >= self.min_ms
]
items.sort(key=lambda kv: kv[1], reverse=True)
items = items[: self.top_n]
if not items:
return f"{_c('top:', dim)} (none >= {self.min_ms:.2f}ms)"
top_str = ", ".join(f"{k}:{v:.2f}ms" for k, v in items)
return f"{_c('top:', dim)} {top_str}"
@staticmethod
def _pipe_row(cells: Iterable[str]) -> str:
# Keeps lines short and avoids long f-strings.
return " | ".join(cells)
[docs]
@dataclass
class FrameTimerConfig:
"""
Configuration for FrameTimer.
:ivar enabled (bool): Whether timing is enabled.
:ivar report_every (int): Number of frames between reports.
"""
enabled: bool = False
report_every: int = 60
[docs]
@dataclass
class FrameTimer:
"""
Simple frame timer for marking and reporting time intervals.
:ivar config (FrameTimerConfig): Configuration for the timer.
:ivar formatter (FrameTimingFormatter): Formatter for timing reports.
:ivar marks (Dict[str, float]): Recorded time marks.
"""
config: FrameTimerConfig = field(default_factory=FrameTimerConfig)
formatter: FrameTimingFormatter = field(
default_factory=FrameTimingFormatter
)
marks: Dict[str, float] = field(default_factory=dict)
[docs]
def clear(self):
"""Clear all recorded marks."""
if not self.config.enabled:
return
self.marks.clear()
[docs]
def mark(self, name: str):
"""
Record a time mark with the given name.
:param name: Name of the mark.
:type name: str
"""
if not self.config.enabled:
return
self.marks[name] = perf_counter()
[docs]
def report_ms(self) -> Dict[str, float]:
"""
Returns diffs between consecutive marks in insertion order.
:return: Dictionary mapping "start->end" to time difference in milliseconds.
:rtype: Dict[str, float]
"""
if not self.config.enabled:
return {}
keys = list(self.marks.keys())
out: Dict[str, float] = {}
for a, b in zip(keys, keys[1:]):
out[f"{a}->{b}"] = (self.marks[b] - self.marks[a]) * 1000.0
return out
[docs]
def should_report(self, frame_index: int) -> bool:
"""
Determine if a report should be emitted for the given frame index.
:param frame_index: Current frame index.
:type frame_index: int
:return: True if a report should be emitted, False otherwise.
:rtype: bool
"""
return (
self.config.enabled
and self.config.report_every > 0
and frame_index > 0
and (frame_index % self.config.report_every == 0)
)
[docs]
def emit(self, frame_index: int):
"""
Emit a timing report to the performance logger.
:param frame_index: Current frame index.
:type frame_index: int
"""
if not self.config.enabled:
return
diffs = self.report_ms()
report = self.formatter.make_report(frame_index, diffs)
perf_logger.info(self.formatter.format(report))