ophtml 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ophtml-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: ophtml
3
+ Version: 0.3.0
4
+ Summary: Bakes the ps2ui intermediate representation into .uib blobs for the PlayStation 2 runtime
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/coffeedevsolutions/OPHTML
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: Pillow>=9
10
+
11
+ # ophtml
12
+
13
+ The Python half of the OPHTML toolchain, published to PyPI as
14
+ `ophtml`. It provides the `ps2ui`, `ps2ui-bake`, `ps2ui-check` and
15
+ `ps2ui-fontgen` commands. OPHTML is the product, ps2ui is the format
16
+ and the tools that speak it.
17
+
18
+ Second stage of the [ps2ui toolchain](https://github.com/coffeedevsolutions/OPHTML/blob/main/README.md): turns the
19
+ `ui.json` IR produced by `@ophtml/layout` into a `.uib` blob the C99
20
+ runtime replays on the PlayStation 2, plus PNG previews rendered by
21
+ replaying that same blob.
22
+
23
+ ```sh
24
+ PYTHONPATH=. python3 -m ps2ui_bake ui.json -o ui.uib --preview out.png
25
+ ```
26
+
27
+ `ps2ui serve` puts that same replay behind a localhost page with
28
+ arrow-key navigation, screen and theme switching, four aspect modes and
29
+ click-to-inspect over the command list. `--uib blob.uib` serves any
30
+ `.uib` with no project and no Node. It renders through the previewer
31
+ rather than in the browser, so it shows what the console draws; it is
32
+ not a substitute for running on one. See the repository README.
33
+
34
+ `pip install -e .` from this directory puts `ps2ui`, `ps2ui-bake`,
35
+ `ps2ui-check` and `ps2ui-fontgen` on `PATH` as bare commands, pointed at
36
+ the checkout, so the `PYTHONPATH=` prefix is only needed when nothing is
37
+ installed, which is the case CI runs in.
38
+
39
+ See `docs/format-uib.md` at the repository root for the file format,
40
+ and `ps2ui_bake/rounding.py` for the numeric rules shared with the
41
+ layout stage.
ophtml-0.3.0/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # ophtml
2
+
3
+ The Python half of the OPHTML toolchain, published to PyPI as
4
+ `ophtml`. It provides the `ps2ui`, `ps2ui-bake`, `ps2ui-check` and
5
+ `ps2ui-fontgen` commands. OPHTML is the product, ps2ui is the format
6
+ and the tools that speak it.
7
+
8
+ Second stage of the [ps2ui toolchain](https://github.com/coffeedevsolutions/OPHTML/blob/main/README.md): turns the
9
+ `ui.json` IR produced by `@ophtml/layout` into a `.uib` blob the C99
10
+ runtime replays on the PlayStation 2, plus PNG previews rendered by
11
+ replaying that same blob.
12
+
13
+ ```sh
14
+ PYTHONPATH=. python3 -m ps2ui_bake ui.json -o ui.uib --preview out.png
15
+ ```
16
+
17
+ `ps2ui serve` puts that same replay behind a localhost page with
18
+ arrow-key navigation, screen and theme switching, four aspect modes and
19
+ click-to-inspect over the command list. `--uib blob.uib` serves any
20
+ `.uib` with no project and no Node. It renders through the previewer
21
+ rather than in the browser, so it shows what the console draws; it is
22
+ not a substitute for running on one. See the repository README.
23
+
24
+ `pip install -e .` from this directory puts `ps2ui`, `ps2ui-bake`,
25
+ `ps2ui-check` and `ps2ui-fontgen` on `PATH` as bare commands, pointed at
26
+ the checkout, so the `PYTHONPATH=` prefix is only needed when nothing is
27
+ installed, which is the case CI runs in.
28
+
29
+ See `docs/format-uib.md` at the repository root for the file format,
30
+ and `ps2ui_bake/rounding.py` for the numeric rules shared with the
31
+ layout stage.
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: ophtml
3
+ Version: 0.3.0
4
+ Summary: Bakes the ps2ui intermediate representation into .uib blobs for the PlayStation 2 runtime
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/coffeedevsolutions/OPHTML
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: Pillow>=9
10
+
11
+ # ophtml
12
+
13
+ The Python half of the OPHTML toolchain, published to PyPI as
14
+ `ophtml`. It provides the `ps2ui`, `ps2ui-bake`, `ps2ui-check` and
15
+ `ps2ui-fontgen` commands. OPHTML is the product, ps2ui is the format
16
+ and the tools that speak it.
17
+
18
+ Second stage of the [ps2ui toolchain](https://github.com/coffeedevsolutions/OPHTML/blob/main/README.md): turns the
19
+ `ui.json` IR produced by `@ophtml/layout` into a `.uib` blob the C99
20
+ runtime replays on the PlayStation 2, plus PNG previews rendered by
21
+ replaying that same blob.
22
+
23
+ ```sh
24
+ PYTHONPATH=. python3 -m ps2ui_bake ui.json -o ui.uib --preview out.png
25
+ ```
26
+
27
+ `ps2ui serve` puts that same replay behind a localhost page with
28
+ arrow-key navigation, screen and theme switching, four aspect modes and
29
+ click-to-inspect over the command list. `--uib blob.uib` serves any
30
+ `.uib` with no project and no Node. It renders through the previewer
31
+ rather than in the browser, so it shows what the console draws; it is
32
+ not a substitute for running on one. See the repository README.
33
+
34
+ `pip install -e .` from this directory puts `ps2ui`, `ps2ui-bake`,
35
+ `ps2ui-check` and `ps2ui-fontgen` on `PATH` as bare commands, pointed at
36
+ the checkout, so the `PYTHONPATH=` prefix is only needed when nothing is
37
+ installed, which is the case CI runs in.
38
+
39
+ See `docs/format-uib.md` at the repository root for the file format,
40
+ and `ps2ui_bake/rounding.py` for the numeric rules shared with the
41
+ layout stage.
@@ -0,0 +1,31 @@
1
+ README.md
2
+ pyproject.toml
3
+ ophtml.egg-info/PKG-INFO
4
+ ophtml.egg-info/SOURCES.txt
5
+ ophtml.egg-info/dependency_links.txt
6
+ ophtml.egg-info/entry_points.txt
7
+ ophtml.egg-info/requires.txt
8
+ ophtml.egg-info/top_level.txt
9
+ ps2ui_bake/__init__.py
10
+ ps2ui_bake/__main__.py
11
+ ps2ui_bake/arena.py
12
+ ps2ui_bake/atlas.py
13
+ ps2ui_bake/caps.py
14
+ ps2ui_bake/check.py
15
+ ps2ui_bake/cli.py
16
+ ps2ui_bake/clip.py
17
+ ps2ui_bake/fontgen.py
18
+ ps2ui_bake/gs.py
19
+ ps2ui_bake/ninepatch.py
20
+ ps2ui_bake/pen.py
21
+ ps2ui_bake/preview.py
22
+ ps2ui_bake/project.py
23
+ ps2ui_bake/ps2ui.py
24
+ ps2ui_bake/quads.py
25
+ ps2ui_bake/rounding.py
26
+ ps2ui_bake/serve.py
27
+ ps2ui_bake/serve_page.html
28
+ ps2ui_bake/uib.py
29
+ ps2ui_bake/vram.py
30
+ tests/test_baker.py
31
+ tests/test_serve.py
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ ps2ui = ps2ui_bake.ps2ui:main
3
+ ps2ui-bake = ps2ui_bake.cli:main
4
+ ps2ui-check = ps2ui_bake.check:main
5
+ ps2ui-fontgen = ps2ui_bake.fontgen:main
@@ -0,0 +1 @@
1
+ Pillow>=9
@@ -0,0 +1 @@
1
+ ps2ui_bake
@@ -0,0 +1,31 @@
1
+ """ps2ui-bake: turns the ui.json IR into a .uib blob the PS2 runtime replays.
2
+
3
+ Stages inside this package:
4
+
5
+ fontgen TTF -> metrics JSON (the seam shared with @ophtml/layout)
6
+ atlas glyph atlases per (weight, size), 8-bit coverage + CLUT
7
+ ninepatch rounded-rect chrome -> 9-sliced RGBA patches
8
+ quads IR commands -> flat GS quad records
9
+ uib binary .uib writer / reader
10
+ preview replays a baked blob to PNG, montages focus states
11
+
12
+ The one rule: everything the console would otherwise compute happens here.
13
+ """
14
+
15
+ # THE ONE PLACE THIS PACKAGE'S VERSION IS WRITTEN. pyproject.toml reads
16
+ # it (`dynamic = ["version"]`), because from the 0.2.0 release onward
17
+ # this file said 0.1.0 while pyproject said 0.2.0 -- two numbers for one
18
+ # package, neither read by anything, so neither could be wrong out loud.
19
+ #
20
+ # 0.3.0 is a release and names the tag `v0.3.0`. It carried `.dev0`
21
+ # until that tag existed, because there is no 0.2.0 tag and never was:
22
+ # the tree was past that release by four format moves (v4-v7) and was
23
+ # not yet the next one. What a prerelease does and does NOT protect
24
+ # against is in docs/releasing.md -- pip's exclusion of prereleases
25
+ # lapses when no stable version exists, which is why the first PyPI
26
+ # upload has to be a real release rather than a reserved name.
27
+ # tools/check-versions.py holds all of it together, and its rule 8
28
+ # fails this line the moment it names a release nothing has tagged.
29
+ __version__ = "0.3.0"
30
+
31
+ from .rounding import round_half_up, css_alpha_to_gs, gs_alpha_to_css # noqa: F401
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,77 @@
1
+ """The v6 arena requirement, computed from a baked blob.
2
+
3
+ `ps2ui_load` no longer sizes itself from PS2UI_MAX_* ceilings: it carves
4
+ one caller-provided block, and the caller has to know how big it is
5
+ before running anything. This is the host-side mirror of `arena_compute`
6
+ in runtime/ps2ui.c, so `ps2ui-bake` and `ps2ui-check` can print the
7
+ number an integrator needs to declare a static buffer.
8
+
9
+ THE NUMBER IS TARGET-DEPENDENT, which is the only subtle thing here.
10
+ The arena holds an array of gsKit's GSTEXTURE, and that struct contains
11
+ two pointers (`u32 *Mem`, `u32 *Clut`). On the EE they are 4 bytes; on a
12
+ 64-bit host they are 8, so the same blob needs a different arena in the
13
+ sample ELF than in the host test suite. Reporting one number without
14
+ saying which target it belongs to would be wrong roughly half the time,
15
+ so both are computed and the console one is the headline -- that is
16
+ where the static buffer actually lives.
17
+
18
+ test_arena_matches_runtime proves this file agrees with the C: two
19
+ implementations that agree is evidence, one restating the other's
20
+ comments is not.
21
+ """
22
+
23
+ # GSTEXTURE, from runtime/vendor/gsKit/gsInit.h (u32 Width, u32 Height,
24
+ # u8 PSM, u8 ClutPSM, u32 TBW, u32 *Mem, u32 *Clut, u32 Vram,
25
+ # u32 VramClut, u32 Filter, u8 ClutStorageMode, u8 Delayed), laid out by
26
+ # the ordinary C rules rather than by a remembered total.
27
+ _GSTEXTURE_FIELDS = [
28
+ (4, 4), (4, 4), # Width, Height
29
+ (1, 1), (1, 1), # PSM, ClutPSM
30
+ (4, 4), # TBW
31
+ ("ptr", "ptr"), ("ptr", "ptr"), # Mem, Clut
32
+ (4, 4), (4, 4), (4, 4), # Vram, VramClut, Filter
33
+ (1, 1), (1, 1), # ClutStorageMode, Delayed
34
+ ]
35
+
36
+ EE_PTR = 4 # mips64r5900el-ps2-elf: 32-bit pointers
37
+ HOST64_PTR = 8
38
+
39
+
40
+ def sizeof_gstexture(ptr_size: int = EE_PTR) -> int:
41
+ off = 0
42
+ align_max = 1
43
+ for size, align in _GSTEXTURE_FIELDS:
44
+ if size == "ptr":
45
+ size = align = ptr_size
46
+ off = (off + align - 1) // align * align
47
+ off += size
48
+ align_max = max(align_max, align)
49
+ return (off + align_max - 1) // align_max * align_max
50
+
51
+
52
+ def arena_size(uib, ptr_size: int = EE_PTR) -> int:
53
+ """Bytes ps2ui_load needs for `uib`, for a target with this pointer
54
+ size. Mirrors arena_compute's regions in the same order."""
55
+ total = 0
56
+ total += len(uib.cluts) * 256 * 4 # permuted CLUTs
57
+ total += len(uib.textures) * sizeof_gstexture(ptr_size)
58
+ total += len(uib.slots) * 4 # slot_off[]
59
+ total += ((len(uib.focus) + 31) // 32) * 4 # hidden bits
60
+ total += len(uib.screens) * 2 # screen_focus[]
61
+ for sl in uib.slots:
62
+ total += sl["capacity"] + 1 # slot text
63
+ total += len(uib.slots) # slot_is_set
64
+ return total
65
+
66
+
67
+ def breakdown(uib, ptr_size: int = EE_PTR) -> list:
68
+ """(region, bytes) pairs, for a report that says where it went."""
69
+ return [
70
+ ("permuted CLUTs", len(uib.cluts) * 256 * 4),
71
+ ("texture handles", len(uib.textures) * sizeof_gstexture(ptr_size)),
72
+ ("slot text", sum(sl["capacity"] + 1 for sl in uib.slots)),
73
+ ("slot offsets", len(uib.slots) * 4),
74
+ ("visibility bits", ((len(uib.focus) + 31) // 32) * 4),
75
+ ("screen focus", len(uib.screens) * 2),
76
+ ("slot set flags", len(uib.slots)),
77
+ ]
@@ -0,0 +1,154 @@
1
+ """Glyph atlas baking.
2
+
3
+ One atlas per (weight, size) pair used by the document. Glyphs are
4
+ rasterized by FreeType (via Pillow) at the exact pixel size the layout
5
+ measured — never scaled on the GS, where bilinear filtering would turn
6
+ 14px text into soup.
7
+
8
+ The atlas is PSMT8: each texel is a coverage value 0..255 indexing a
9
+ CLUT of white-with-alpha-ramp, and the runtime tints by vertex color
10
+ modulation. One atlas therefore serves every text color in the UI.
11
+
12
+ Glyph advances are re-derived from the *metrics JSON*, not from what
13
+ FreeType reports at this size — layout already positioned every line
14
+ using metrics advances, and the bitmap must follow the measurement, not
15
+ the other way around. The bitmap's own bearing places ink within the
16
+ advance box.
17
+ """
18
+
19
+ import json
20
+ from dataclasses import dataclass, field
21
+
22
+ from PIL import Image, ImageDraw, ImageFont
23
+
24
+ from .rounding import glyph_advance_px, kern_px
25
+
26
+ ATLAS_WIDTH = 256 # GS-friendly; height grows in shelf packing
27
+
28
+
29
+ @dataclass
30
+ class Glyph:
31
+ codepoint: int
32
+ u: int
33
+ v: int
34
+ w: int
35
+ h: int
36
+ bearing_x: int # ink offset from pen x
37
+ bearing_y: int # ink offset from the line-box top, measured via the
38
+ # METRICS ascent — see AtlasBuilder.add (backlog B2)
39
+ advance: int # px, from metrics — matches layout exactly
40
+
41
+
42
+ @dataclass
43
+ class Atlas:
44
+ weight: int
45
+ size: int
46
+ image: "Image.Image"
47
+ glyphs: dict = field(default_factory=dict) # codepoint -> Glyph
48
+ line_ascent: int = 0
49
+ line_descent: int = 0
50
+
51
+
52
+ class AtlasBuilder:
53
+ """Shelf-packs glyphs for one (weight, size)."""
54
+
55
+ def __init__(self, ttf_path: str, metrics_path: str, weight: int, size: int):
56
+ self.weight = weight
57
+ self.size = size
58
+ self.font = ImageFont.truetype(ttf_path, size)
59
+ with open(metrics_path, encoding="utf-8") as fh:
60
+ self.metrics = json.load(fh)
61
+ self.image = Image.new("L", (ATLAS_WIDTH, 64), 0)
62
+ self.draw = ImageDraw.Draw(self.image)
63
+ self.glyphs = {}
64
+ self.shelf_x = 1
65
+ self.shelf_y = 1
66
+ self.shelf_h = 0
67
+ ascent, descent = self.font.getmetrics()
68
+ self.ascent = ascent
69
+ self.descent = descent
70
+ # The layout stage positioned every line box using the METRICS
71
+ # ascent (units -> px via the shared rounding rule). Pillow's
72
+ # per-size ascent can differ by a pixel, so ink placement must
73
+ # go through the metrics value or text drifts off its measured
74
+ # box (backlog B2).
75
+ self.metrics_ascent_px = glyph_advance_px(self.metrics["ascent"], size)
76
+ self.kerning = self.metrics.get("kerning", {})
77
+
78
+ def kern(self, prev_cp, cp: int) -> int:
79
+ """Pixel kern applied before `cp` because `prev_cp` precedes it.
80
+
81
+ Mirrors layout's Font.kernPx, including that the first glyph of
82
+ a run is never kerned."""
83
+ if prev_cp is None:
84
+ return 0
85
+ return kern_px(self.kerning.get(f"{prev_cp},{cp}", 0), self.size)
86
+
87
+ def _grow(self, needed_h: int):
88
+ if needed_h <= self.image.height:
89
+ return
90
+ h = self.image.height
91
+ while h < needed_h:
92
+ h *= 2
93
+ grown = Image.new("L", (ATLAS_WIDTH, h), 0)
94
+ grown.paste(self.image, (0, 0))
95
+ self.image = grown
96
+ self.draw = ImageDraw.Draw(self.image)
97
+
98
+ def add(self, ch: str) -> Glyph:
99
+ cp = ord(ch)
100
+ if cp in self.glyphs:
101
+ return self.glyphs[cp]
102
+
103
+ adv_units = self.metrics["advances"].get(str(cp), self.metrics["missing"])
104
+ advance = glyph_advance_px(adv_units, self.size)
105
+
106
+ # Rasterize into a scratch tile and crop to ink.
107
+ pad = 2
108
+ tile_w = advance + self.size + 2 * pad
109
+ tile_h = self.ascent + self.descent + 2 * pad
110
+ tile = Image.new("L", (tile_w, tile_h), 0)
111
+ ImageDraw.Draw(tile).text((pad, pad), ch, font=self.font, fill=255)
112
+ bbox = tile.getbbox()
113
+ if bbox is None: # space & friends: advance with no ink
114
+ g = Glyph(cp, 0, 0, 0, 0, 0, 0, advance)
115
+ self.glyphs[cp] = g
116
+ return g
117
+ x0, y0, x1, y1 = bbox
118
+ ink = tile.crop(bbox)
119
+ w, h = ink.size
120
+
121
+ if self.shelf_x + w + 1 > ATLAS_WIDTH:
122
+ self.shelf_y += self.shelf_h + 1
123
+ self.shelf_x = 1
124
+ self.shelf_h = 0
125
+ self._grow(self.shelf_y + h + 1)
126
+ self.image.paste(ink, (self.shelf_x, self.shelf_y))
127
+
128
+ # Pillow's default anchor puts the FreeType ascender at y=pad,
129
+ # so (y0 - pad) is ink-below-PIL-ascender. Re-express it as
130
+ # ink-below-baseline, then hang it from the metrics ascent that
131
+ # layout actually measured with.
132
+ ink_from_baseline = (y0 - pad) - self.ascent
133
+ g = Glyph(
134
+ codepoint=cp,
135
+ u=self.shelf_x, v=self.shelf_y, w=w, h=h,
136
+ bearing_x=x0 - pad,
137
+ bearing_y=self.metrics_ascent_px + ink_from_baseline,
138
+ advance=advance,
139
+ )
140
+ self.shelf_x += w + 1
141
+ self.shelf_h = max(self.shelf_h, h)
142
+ self.glyphs[cp] = g
143
+ return g
144
+
145
+ def build(self) -> Atlas:
146
+ # Snap height up to the next multiple of 8 lest odd heights upset
147
+ # GS transfer alignment.
148
+ h = (self.image.height + 7) & ~7
149
+ if h != self.image.height:
150
+ self._grow(h)
151
+ atlas = Atlas(self.weight, self.size, self.image, dict(self.glyphs))
152
+ atlas.line_ascent = self.ascent
153
+ atlas.line_descent = self.descent
154
+ return atlas
@@ -0,0 +1,139 @@
1
+ """Runtime limits the bake has to know about (backlog B10).
2
+
3
+ Originally four table-count ceilings that ps2ui_load() rejected with
4
+ PS2UI_ERR_TOO_MANY. Three of them -- textures, slots, screens -- no
5
+ longer exist: once the v6 arena made the context size itself from the
6
+ blob, those numbers bounded nothing the blob's own size did not
7
+ already bound, and 16 slots was a real obstacle to a real UI (the UC-3
8
+ scoping fixture measures 28 on one OPL-class screen). The runtime now
9
+ refuses on arithmetic it cannot do rather than on a number ps2ui.h
10
+ picked; see arena_compute.
11
+
12
+ What remains is PS2UI_MAX_SCISSOR_DEPTH, which is genuine fixed
13
+ storage: ps2ui_render keeps a scissor stack that deep, and a blob
14
+ nesting `overflow: hidden` past it draws the inner subtree under the
15
+ outer clip. It fails soft, on a television, so the bake refuses it
16
+ here instead.
17
+
18
+ The value is parsed out of runtime/ps2ui.h when it is reachable, so
19
+ raising it in the header raises it here too. FALLBACK covers the
20
+ pip-installed case where the runtime source is absent;
21
+ test_caps_match_header proves the two agree.
22
+ """
23
+
24
+ import os
25
+ import re
26
+
27
+ FALLBACK = {
28
+ # The last one. It bounds how deep `overflow: hidden` may nest
29
+ # before ps2ui_render runs out of scissor stack -- real storage,
30
+ # unlike the table counts that used to sit beside it. The regex
31
+ # already matched it; without the key here, caps.update silently
32
+ # dropped it and nothing checked the depth at all.
33
+ "PS2UI_MAX_SCISSOR_DEPTH": 8,
34
+ }
35
+
36
+ _DEFINE = re.compile(r"^#define\s+(PS2UI_MAX_\w+)\s+(\d+)", re.M)
37
+
38
+
39
+ def header_path() -> str:
40
+ here = os.path.dirname(os.path.abspath(__file__))
41
+ return os.path.normpath(os.path.join(here, "..", "..", "..", "runtime", "ps2ui.h"))
42
+
43
+
44
+ def parse_header(path: str = None) -> dict:
45
+ """Caps from ps2ui.h, or FALLBACK when the header is not reachable."""
46
+ path = path or header_path()
47
+ try:
48
+ with open(path, encoding="utf-8") as fh:
49
+ src = fh.read()
50
+ except OSError:
51
+ return dict(FALLBACK)
52
+ found = {m.group(1): int(m.group(2)) for m in _DEFINE.finditer(src)}
53
+ caps = dict(FALLBACK)
54
+ caps.update({k: v for k, v in found.items() if k in FALLBACK})
55
+ return caps
56
+
57
+
58
+ def max_scissor_depth(records) -> int:
59
+ """Deepest SCISSOR_PUSH nesting in a command list.
60
+
61
+ The runtime keeps a fixed stack and cannot report an overflow. It
62
+ fails soft — the too-deep subtree draws under the enclosing clip —
63
+ but "your dialog is not clipped" is a poor thing to discover on a
64
+ television, so the bake refuses it instead.
65
+ """
66
+ from .quads import OP_SCISSOR_POP, OP_SCISSOR_PUSH
67
+ depth = peak = 0
68
+ for rec in records:
69
+ if rec.op == OP_SCISSOR_PUSH:
70
+ depth += 1
71
+ peak = max(peak, depth)
72
+ elif rec.op == OP_SCISSOR_POP and depth > 0:
73
+ depth -= 1
74
+ return peak
75
+
76
+
77
+ def check(textures, cluts, slots, screens, caps: dict = None, records=None):
78
+ """Returns (errors, caps). Each error names the cap, the value, and
79
+ the header constant to raise if the limit is the wrong one."""
80
+ caps = caps or parse_header()
81
+ errors = []
82
+
83
+ if records is not None:
84
+ # >= because the runtime's guard is `depth + 1 >= MAX`, so the
85
+ # last usable slot is MAX - 1.
86
+ peak = max_scissor_depth(records)
87
+ limit = caps["PS2UI_MAX_SCISSOR_DEPTH"]
88
+ if peak >= limit:
89
+ errors.append(
90
+ f"scissor nesting: {peak} levels reaches "
91
+ f"PS2UI_MAX_SCISSOR_DEPTH = {limit}. ps2ui_render has a "
92
+ f"fixed stack and cannot report an overflow, so the "
93
+ f"deepest subtree would draw under its parent's clip "
94
+ f"instead of its own. Flatten the nesting or raise "
95
+ f"PS2UI_MAX_SCISSOR_DEPTH in runtime/ps2ui.h."
96
+ )
97
+
98
+ # Textures, slots and screens used to be checked against ceilings
99
+ # here. They are not bounded by a number any more -- only by the
100
+ # format's own uint16 count fields, which the writer below cannot
101
+ # exceed without failing to pack the header at all. What replaced
102
+ # the ceilings is an arena the runtime refuses to carve if it does
103
+ # not fit the target's address space, and the bake already reports
104
+ # that arena in bytes, which is the number an integrator can act on.
105
+ for what, count in (("textures", len(textures)), ("slots", len(slots)),
106
+ ("screens", len(screens)), ("cluts", len(cluts))):
107
+ if count > 0xFFFF:
108
+ errors.append(
109
+ f"{what}: {count} does not fit the format's uint16 count "
110
+ f"field. This is the format's own limit, not a runtime one."
111
+ )
112
+
113
+ # Capacity used to be checked against PS2UI_SLOT_BUFSZ, the
114
+ # runtime's fixed per-slot buffer. There is no such buffer any more
115
+ # (v6 resource model): the runtime sizes each slot's storage from
116
+ # the capacity declared here, so a large capacity costs arena bytes
117
+ # rather than being unloadable. The remaining bound is the format's
118
+ # own -- capacity is a uint16 field.
119
+ for sl in slots:
120
+ if sl["capacity"] > 0xFFFF:
121
+ errors.append(
122
+ f"slot {sl['name']!r}: capacity {sl['capacity']} does not fit "
123
+ f"the format's uint16 capacity field."
124
+ )
125
+
126
+ return errors, caps
127
+
128
+
129
+ def summary(textures, cluts, slots, screens, caps: dict) -> str:
130
+ # Counts, not fractions. "15/16 slots" was a useful line while the
131
+ # denominator was a wall you could hit; printing a fraction of
132
+ # 65535 would just be a number with a decorative second half, and
133
+ # the figure that actually constrains a UI now -- the arena -- is
134
+ # printed on its own line by cli.py.
135
+ del caps
136
+ return (
137
+ f" runtime tables: {len(textures)} textures, {len(cluts)} CLUTs, "
138
+ f"{len(slots)} slots, {len(screens)} screens"
139
+ )