diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c31c720..d111f10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,26 @@ jobs: - name: Build and test run: make check + renderer-tests: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Install uv and Python + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.14" + + - name: Test renderer + working-directory: renderer + run: uv run --locked pytest -q + strict-compilers: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 6f5d9d1..7482fc4 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ tileability. ## Current status -Implemented and tested as of 24 August 2026: +Implemented and tested as of 25 August 2026: - canonical static definition of the 23 atomic Wang tiles; - generalized-tile family metadata kept outside solver semantics; @@ -243,6 +243,9 @@ Implemented and tested as of 24 August 2026: - a JSON Lines comparison suite over fixed `.cm13` inputs, with separate prepared-Region and file-to-verified-decision scopes for the native reference, native optimized, Boolean Z3, and Wang Z3 paths; +- a provenance-pinned PAP Render snapshot under `renderer/`, preserving its + standalone Python 3.14 project and 144-test suite without coupling its + graphical dependencies to the native core or Python reference tools; - C17/OpenMP build scaffold and GitHub Actions CI with strict GCC/Clang, ASan, UBSan, GCC static analysis, Memcheck, and Cachegrind paths. @@ -284,6 +287,19 @@ make clean make check ``` +The imported renderer remains a separate locked Python project. Its Pillow and +NumPy dependencies are not installed by the root project or exercised by +`make check`. Run its 144 tests independently: + +```sh +cd renderer +uv run --locked pytest -q +``` + +CI mirrors that command in a separate read-only Python 3.14 job. Snapshot +provenance and update instructions are recorded in +[`renderer/UPSTREAM.md`](renderer/UPSTREAM.md). + Useful individual targets: ```sh diff --git a/renderer/.github/workflows/ci.yml b/renderer/.github/workflows/ci.yml new file mode 100644 index 0000000..38b0ff5 --- /dev/null +++ b/renderer/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test (pytest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + + - name: Run test suite + run: uv run pytest -q diff --git a/renderer/.gitignore b/renderer/.gitignore new file mode 100644 index 0000000..e252dab --- /dev/null +++ b/renderer/.gitignore @@ -0,0 +1,13 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Project notes / assignment (not part of the deliverable) +CLAUDE.md diff --git a/renderer/.python-version b/renderer/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/renderer/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/renderer/LICENSE b/renderer/LICENSE new file mode 100644 index 0000000..2ac5e28 --- /dev/null +++ b/renderer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Manuel Magnabosco + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/renderer/README.md b/renderer/README.md new file mode 100644 index 0000000..dbe89c8 --- /dev/null +++ b/renderer/README.md @@ -0,0 +1,249 @@ +# PAP Render + +[![CI](https://github.com/xtraid/PAP_render/actions/workflows/ci.yml/badge.svg)](https://github.com/xtraid/PAP_render/actions/workflows/ci.yml) + +A command-line pixel art renderer that composites a scene from a tile-based background and a list of sprites, exporting the result as a PNG image. + +**Status**: feature-complete. The code works and is tested; any future changes will be refactoring, cleanup, or optimization. + +## Output + +![example render](output/example.png) + +## Overview + +- 16-color indexed palette (4 bit per pixel) +- Tile sheet: 64 tiles of 32×32 pixels in a 256×256 image +- Sprite sheet: 16 sprites of 64×64 pixels in a 256×256 image +- Frame buffer: 640×480 pixels (tile map 15×20) +- Sprite transformations: flip on x/y axis, rotation by 90/180/270 degrees +- Transparency via a designated palette index + +## Usage + +```bash +python main.py +``` + +| Argument | Description | +|---|---| +| `palette.json` | 16 RGB colors | +| `scene.json` | transparent index, tile map, sprite list | +| `tiles.bin` | packed binary tile sheet (32768 bytes) | +| `sprites.bin` | packed binary sprite sheet (32768 bytes) | +| `output.png` | rendered output | + +## Input Format + +### palette.json + +Array of 16 RGB colors: + +```json +[ + [0, 0, 0], + [255, 0, 0], + ... +] +``` + +### scene.json + +```json +{ + "transparent_index": 0, + "tile_map": [[1, 0, 2, ...], ...], + "sprites": [ + { "id": 0, "x": 100, "y": 80, "flip_x": false, "flip_y": false, "rotation": 0 } + ] +} +``` + +- `transparent_index`: palette index (0–15) treated as transparent for all sprites +- `tile_map`: 15×20 matrix of tile IDs (0–63) +- `sprites`: ordered list — draw order determines z-order + +### .bin files + +Packed binary, 256×256 pixels stored as 32768 bytes. Each byte contains 2 pixels: +- high nibble (bits 7–4): first pixel → palette index 0–15 +- low nibble (bits 3–0): second pixel → palette index 0–15 + +## Architecture + +| Class | Responsibility | Status | +|---|---|---| +| `Palette` | Reads and validates `palette.json`, maps index → RGB | Done | +| `VirtualVRAM` | Loads `.bin` files, decodes nibble-packed pixels into index matrices; exposes `get_tile(id)` and `get_sprite(id)` | Done | +| `SceneParser` | Reads and validates `scene.json`, returns `transparent_index`, `tile_map`, and `sprites` | Done | +| `Blitter` | Composites tiles and sprites onto a 640×480 frame buffer; applies flip/rotation and transparency | Done | +| `RenderingPipeline` | Orchestrates the full render and exports PNG | Done | + +Custom exceptions (`PaletteError`, `VRAMError`, `SceneError`, `BlitterException`, `RenderingException`) are raised for all invalid input cases. `FileNotFoundError` propagates with a descriptive message from all file-loading classes. + +## API Reference + +### Palette + +```python +Palette(path: str) +``` + +| Member | Type | Description | +|---|---|---| +| `data` | `np.ndarray (16, 3) uint8` | Full palette array | +| `__getitem__(idx: int)` | `np.ndarray (3,) uint8` | RGB color at palette index `idx` ∈ [0, 15] | +| `print_palette()` | `None` | Prints palette to stdout | + +Raises `PaletteError` on invalid palette. Raises `FileNotFoundError` if file is missing. + +--- + +### VirtualVRAM + +```python +VirtualVRAM(path_t: str, path_s: str) +``` + +| Member | Type | Description | +|---|---|---| +| `get_tile(idx: int)` | `np.ndarray (32, 32) uint8` | Palette-index matrix for tile `idx` ∈ [0, 63] | +| `get_sprite(idx: int)` | `np.ndarray (64, 64) uint8` | Palette-index matrix for sprite `idx` ∈ [0, 15] | + +Raises `VRAMError` on invalid file size. Raises `FileNotFoundError` if a file is missing. + +--- + +### SceneParser + +```python +SceneParser(path: str) +``` + +| Member | Type | Description | +|---|---|---| +| `transparent_index` | `int` | Palette index treated as transparent, ∈ [0, 15] | +| `tile_map` | `np.ndarray (15, 20) uint8` | Grid of tile IDs | +| `sprites` | `list[dict]` | Ordered sprite list; each dict has `id`, `x`, `y`, `flip_x`, `flip_y`, `rotation` | + +Raises `SceneError` on invalid scene. Raises `FileNotFoundError` if file is missing. + +--- + +### Blitter + +```python +Blitter(vram: VirtualVRAM, asset_type: str, idx: int, transparent_index: int, buffer: np.ndarray) +``` + +| Member | Type | Description | +|---|---|---| +| `transparent_index` | `int` | Palette index treated as transparent | +| `_buffer` | `np.ndarray (480, 640) uint8` | Shared frame buffer written in place | +| `blit_tile(row: int, col: int)` | `None` | Copies tile onto buffer at tilemap cell (`row` ∈ [0,14], `col` ∈ [0,19]) | +| `blit_sprite(x, y, flip_x, flip_y, rotation)` | `None` | Transforms and blits sprite at top-left `(x, y)`; transparent pixels are skipped | +| `_transform(sprite_matrix, flip_x, flip_y, rotation)` | `np.ndarray (64, 64) uint8` | Applies flip then rotation to a sprite matrix | +| `_clip(x: int, y: int)` | `tuple[tuple[slice, slice], tuple[slice, slice]]` | Returns `(dst, src)` slice pairs for a 64×64 sprite at `(x, y)` | + +Raises `BlitterException` on invalid arguments. + +--- + +### RenderingPipeline + +```python +RenderingPipeline(palette_path, scene_path, tiles_path, sprites_path, output_path) +``` + +| Member | Type | Description | +|---|---|---| +| `get_buf()` | `np.ndarray (480, 640) uint8` | classmethod — creates a blank frame buffer | +| `render()` | `None` | Runs full pipeline: compose tiles + sprites, export PNG | +| `_compose(buf: np.ndarray)` | `None` | Fills `buf` with tiles then sprites in scene order | +| `_export(buf: np.ndarray)` | `None` | Maps palette indexes → RGB, saves PNG via Pillow | + +Raises `RenderingException` on pipeline errors. + +## Requirements + +- Python ≥ 3.14 +- `Pillow` used only for PNG export +- `numpy` arrays with `np.uint8` dtype + +## Project Structure + +``` +. +├── main.py # CLI entry point +├── classes.py # Palette, VirtualVRAM, SceneParser, Blitter, RenderingPipeline +├── tests.py # Test suite (144 tests, all passing) +├── input/ # Example input files (palette, scene, tiles, sprites) +├── output/ # Rendered PNG output goes here +├── test_data/ +│ ├── palette_ok.json # Valid 16-color palette +│ ├── palette_wrong_count.json # Only 3 colors (invalid) +│ ├── palette_wrong_value.json # Component > 255 (invalid) +│ ├── test_boundary_values/ # Per-test directories generated at runtime +│ ├── test_vram_load_ok/ # Each contains the files written by that test +│ └── ... # (one subdirectory per test that writes files) +└── pyproject.toml +``` + +## Tests + +```bash +uv run pytest tests.py -v +``` + +144 tests covering all classes: + +**Palette (18 tests)** +- Happy path: load, `__getitem__` first/last, boundary values (0 and 255) +- File errors: file not found, invalid JSON +- Wrong color count: too few, too many, empty +- Wrong color format: fewer than 3 components, more than 3 components +- Out-of-range values: above 255, negative, exact 255 +- `__getitem__` bounds: index 16 and index −1 + +**VirtualVRAM — decode (8 tests)** +- Happy path: load, shape and dtype, all-zeros decode, all-`0xFF` decode, nibble split (`0xAB` → 10, 11) +- File errors: tiles not found, sprites not found +- Wrong size: tiles file too short, sprites file too short + +**VirtualVRAM — get_tile (7 tests)** +- Happy path: shape and dtype, first tile (id 0) all 15, last tile (id 63) all 15 +- Isolation: tile 5 filled, tile 0 untouched +- Errors: non-int id, id 64, id −1 + +**VirtualVRAM — get_sprite (7 tests)** +- Happy path: shape and dtype, first sprite (id 0) all 15, last sprite (id 15) all 15 +- Isolation: sprite 7 filled, sprite 0 untouched +- Errors: non-int id, id 16, id −1 + +**SceneParser (33 tests)** +- Happy path: load, transparent_index, tile_map shape and dtype, sprites list, boundary transparent_index 15, empty sprites, sprite fields +- File errors: file not found, invalid JSON +- Missing keys: transparent_index, tile_map, sprites +- transparent_index errors: not int, too high (16), negative +- tile_map errors: wrong rows, wrong cols, jagged rows, value 64, negative value, value not int +- sprites errors: not a list, missing field, id not int, id out of range, x/y not int, flip_x/flip_y not bool, rotation not int, rotation invalid (45) + +**Blitter (49 tests)** +- init/_validate: valid tile/sprite, all error cases (asset_type, idx, transparent_index) +- blit_tile: correct write and position, all error cases (row/col type and bounds) +- _transform: identity, flip_x, flip_y, rotation 90/180/270, flip_x+y +- _clip: fully inside, centered, clipping on all 4 sides, sprite fully outside frame +- blit_sprite: all-opaque, all-transparent, mixed transparency, position, clipping on all 4 sides, fully outside frame on all 4 sides, transform+clip combined, z-order + +**RenderingPipeline (18 tests)** +- get_buf: shape, dtype, all zeros, independence between calls +- __repr__: all 5 paths present +- _export: file created, image size 640×480, pixel color maps correctly from palette, bad output path raises RenderingException +- _compose: tiles written, full tile_map filled, sprite over tile, transparent sprite not drawn, sprite z-order, sprite transformation applied, sprite clipping at frame edge +- render(): output file created, output size 640×480 + +**main.py (4 tests)** +- `--help` exits with code 0 +- missing arguments exits with non-zero code +- valid input runs and creates output file +- invalid file paths propagate `FileNotFoundError` diff --git a/renderer/UPSTREAM.md b/renderer/UPSTREAM.md new file mode 100644 index 0000000..f4a305f --- /dev/null +++ b/renderer/UPSTREAM.md @@ -0,0 +1,68 @@ +# PAP Render upstream snapshot + +The renderer directory vendors one snapshot of the standalone PAP Render +project. It is intentionally isolated from the Tiling Foundry core and does +not contain a nested Git repository. + +## Provenance + +- Upstream: +- Default branch at import: `main` +- Commit: `3eba839fb0bb80a533415eb7c1c793a9edf1a1e1` +- Commit date: 2026-07-01 +- License: MIT; the upstream license is preserved in `LICENSE` +- Import date: 2026-08-25 + +All files tracked by that commit are present. Production code, tests, assets, +lockfile, project metadata, and license are byte-for-byte unchanged. The sole +documentary correction to the imported snapshot is in `README.md`: its stale +test totals said 135, while collection at the pinned commit yields 144 tests. +The overall and affected section totals now say 144, 18 Palette, 33 +SceneParser, and 49 Blitter tests. This `UPSTREAM.md` file is local provenance, +not an upstream file. + +## Verification + +The isolated upstream project requires Python 3.14 or newer. Its committed +`.python-version` selects Python 3.14, and the import verification used CPython +3.14.6. `pyproject.toml` declares NumPy, Pillow, and pytest; `uv.lock` resolves +them to NumPy 2.4.6, Pillow 12.2.0, and pytest 9.0.3 for this environment. + +From `renderer/`, reproduce the locked Python 3.14 environment and run: + +```sh +uv run --locked pytest --collect-only -q +uv run --locked pytest -q +``` + +The import baseline is 144 collected tests and 144 passing tests. Keep the +renderer environment under `renderer/.venv`; its local `.gitignore` excludes +that environment and generated Python/build files. + +The preserved `renderer/.github/workflows/ci.yml` is nested upstream evidence; +GitHub does not run it as a workflow of the containing repository. The +top-level `.github/workflows/ci.yml` instead runs the same locked suite in a +separate read-only Python 3.14 job. + +## Updating the snapshot + +Treat an update as a separate reviewed change: + +1. Resolve and record the exact upstream commit before copying files. +2. Inspect its default branch, license, dependency metadata, repository + instructions, tracked files, and collected test count. +3. Run its locked suite in a temporary clone before changing this directory. +4. Replace the snapshot from `git archive` so no upstream `.git` directory is + copied. Preserve this provenance file and document every local difference. +5. Re-run the renderer suite and the Tiling Foundry repository gates, then + inspect the complete diff, secrets, and generated artifacts. + +Do not pull or merge an upstream branch directly into this repository. Do not +move Pillow, NumPy, pytest, or renderer code into the root Python project. + +## Rollback + +Before integration, the feature branch is the rollback boundary. After the +import is integrated, revert the complete import commit with `git revert`; +after a later snapshot update, revert that update commit. This keeps rollback +reviewable and preserves the upstream provenance history. diff --git a/renderer/appunti.txt b/renderer/appunti.txt new file mode 100644 index 0000000..17d4743 --- /dev/null +++ b/renderer/appunti.txt @@ -0,0 +1,227 @@ +# OVERVIEW: +- [x] palette 16 colors +- [x] tiles 32*32 +- [x] sprite 64*64 + ~ \forall pixel \implies 4bit \in palette + +- [x] save the config => palette.json + scene.json +- [x] final rendered device => png + + +# INTERFACE AT COMMAND LINE: + +- this program must be executable by command line with the following shape: + python main.py + + +# INPUT FORMAT: + - [x] the renderer must be able to read JSON to get assets, split in 2 separate files: + |- palette.json => palette only + |- scene.json => transparent_index, tile_map, sprites + + - [x] palette \represented as pure JSON + ~ [ + [0,0,0], + [255,0,0], + [0,255,0], + [0,0,255], + [255,255,0], + [0,255,255], + [255,0,255], + [255,255,255], + [128,0,0], + [0,128,0], + [0,0,128], + [128,128,0], + [0,128,128], + [128,0,128], + [128,128,128], + [255,128,0] + ] + + - [x] read tile and sprite binaries + \saved as packed binary as 256*256 matrix + |- packed binary are ( 256 * 256 ) / 2 bytes (file 32768 byte) (each byte contains 2 pixel) + \foreach byte + |- high nibble (bit 7-4) ~ first pixel => palette index 0-15 + |- low nibble (bit 3-0) ~ second pixel => palette index 0-15 + + - [x] the scene is saved in a JSON + \is organized such as + |- 1. transparent_index ( palette index 0-15 treated as transparent ) + |- 2. tile_map ( matrix 15 * 20 of tile IDs 0-63 ) + |- 3. sprites ( list of sprite ) + \where \foreach sprite \has (at least) columns: + |- id + |- x + |- y + |- flip_x + |- flip_y + |- rotation + + ~ { + "transparent_index": 0, + "tile_map": [ + [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [ 1, 0, 0, 5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 0, 0, 0, 1], + [ 1, 0, 0, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 1], + [ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [ 1, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 1], + [ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [ 1, 0, 0, 5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 0, 0, 0, 1], + [ 1, 0, 0, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 1], + [ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + ], + "sprites": [ + { "id": 0, "x": 100, "y": 80, "flip_x": false, "flip_y": false, "rotation": 0 }, + { "id": 3, "x": 320, "y": 200, "flip_x": true, "flip_y": false, "rotation": 0 }, + { "id": 7, "x": 480, "y": 150, "flip_x": false, "flip_y": true, "rotation": 90 }, + { "id": 12, "x": 60, "y": 380, "flip_x": true, "flip_y": true, "rotation": 180 } + ] + } + + +# ARCHITECTURE: +- [x] the palette: + \foreach color + |- is defined as RGB between 0-255 ~ 3 byte + \foreach pixel + |- has just 1 reference to a color ~ 4 bit + +- [x] a tile sheet: + \is 256*256 + |-\has 64 tiles \in grid 8*8 + \foreach tile + |- \has 32*32 pixel + \is ordered + |- tile 0 \is top left + ~ |----------------------------| + | tile 0 tile 1 ... tile 7 | + | tile 8 ... tile 15 | + | . . | + | . . | + | . . | + | | + | tile 56 ... tile 63 | + |----------------------------| + +- [x] a sprite sheet: + \is 256*256 pixel + |-\has 16 sprites \in grid 4*4 + \foreach sprite + |-\has 64*64 pixel + \is ordered + |- sprite 0 top left + ~ |----------------------------------| + | sprite 0 sprite 1 ... sprite 3 | + | sprite 4 ... sprite 7 | + | . . | + | . . | + | . . | + | | + | sprite 12 ... sprite 15 | + |----------------------------------| + +- [x] a frame buffer: + \is 640*480 pixel + \is fondale => whole screen + |-\binds tiles \by tile_map 15*20 ( + - 480/32 = 15 + - 640/32 = 20 + ) + +- [x] transparency: + \exists transparent_index \in palette (0-15) + \foreach sprite, \foreach pixel + - \if pixel palette index == transparent_index => pixel is not copied over the tile on the buffer + - \if pixel palette index != transparent_index => pixel get overwritten on the buffer + +- [x] sprite transformation: + \happens before \binding frame buffer + |-1. reflected on x axis + |-2. reflected on y axis + |-3. rotated by 90,180,270 degree + + +# BUILDING FLOW: + 1. First whole fondale is drawn based on tile_map + 2. Second \foreach sprite is drawn in the order it is saved in json + + N.B. a sprite can be partially out of frame \in this case the pixel drawn are just the \in frame ones + + +# CLASSES: + |- [x] 1. class Palette that takes a JSON path, reads and validates the palette and makes an index to the respective RGB value + |- [x] 2. class VirtualVRAM which uploads the tile sheet and sprite sheet from the respective binaries, decodes the 4 bit sequences in a matrix of palette indexes + |- [x] 3. class SceneParser which reads a JSON and gives back the description of the frame buffer and of the sprites + |- [x] 4. class Blitter which gets tiles and sprites from the respective sheets, applies flip, rotations and transparency and draws the final result in the background + |- [x] 5. class RenderingPipeline which calls and executes the complete composition of the scene and exports it as png + + +# OPTIONS / EXTRA: +- [ ] nibble unpacker in C via ctypes + \instead of pure numpy in VirtualVRAM._decode() + |- write decode(uint8* input, int len, uint8* output) as C shared lib (.so) + |- load in VirtualVRAM via ctypes + |- good ctypes demo: simple interface, bit-level operation that belongs to low level + |- numpy alternative: data>>4 and data&0x0F with interleave (already fast, C not needed for perf) + |- value is in demonstrating ctypes cross-language integration, not raw speed + + +# TODO IF TIME (refactoring design/architettura, non bloccanti — progetto ok cosi): +- [ ] blit_sprite: togliere il chained indexing self._buffer[dst][mask[src]] = ... + |- funziona solo perche dst sono slice pure (basic indexing => view => write-through) + |- fix idiomatico: np.copyto(region, sprite_buf[src], where=sprite_buf[src] != transparent_index) + con region = self._buffer[dst] +- [ ] RenderingPipeline: caricare Palette/VirtualVRAM/SceneParser in cima a render() (fail-fast) + |- ora la palette si valida solo in _export, dopo aver gia composto tutto il buffer + |- passare gli oggetti gia pronti a _compose/_export invece di ricostruirli +- [ ] Blitter: spostare l'id da costruttore a parametro dei metodi (un solo Blitter per scena) + |- ora se ne crea 1 per cella (300+) e si ri-valida transparent_index ogni volta + |- blit_tile(tile_id, row, col) / blit_sprite(sprite_id, x, y, flip_x, flip_y, rotation) + |- N.B. cambia le firme => aggiornare i test (make_pipeline, test Blitter) + + +# REQUIREMENTS: +- in case of error the renderer must raise a related exception, is suggested to make custom exceptions +- each relevant function must be commented with what it does, its input and its output +- Pillow usage must stay in the export to PNG only +- utilize Numpy for arrays, specifically the type np.uint8 +- renderer must be executed by command line in the proposed version + + +# PRE-WRITING CHECKLIST (Python good habits): + +## Naming +- [ ] classi in PascalCase (es. VirtualVRAM, SceneParser) +- [ ] metodi e variabili in snake_case (es. load_json, tile_map) +- [ ] costanti in UPPER_SNAKE_CASE (es. TILE_SIZE = 32) + +## Attributi di istanza +- [ ] self.x => pubblico, parte dell'API esterna +- [ ] self._x => protetto, uso interno / sottoclassi +- [ ] self.__x => privato, name-mangled (_ClassName__x), nessuno lo tocca fuori dalla classe + \scegli __ solo se c'è ereditarietà o vuoi protezione esplicita, altrimenti _ basta + +## Dunder methods +- [ ] __repr__ => sempre, stringa developer-facing (ideale: eval(repr(obj)) ricrea l'oggetto) +- [ ] __str__ => opzionale, stringa human-readable per print() + \se implementi solo uno => implementa __repr__, Python lo usa come fallback +- [ ] __getitem__ => se l'oggetto è indicizzabile con [], usa isinstance() per validare il tipo dell'indice + +## isinstance nei dunder +- [ ] nei metodi che accettano argomenti esterni (operatori, __getitem__, ecc.) + usare isinstance(x, T) per dare errori chiari invece di TypeError oscuri + es: + if not isinstance(idx, int): + raise MyError(f"expected int, got {type(idx)}") + +## Eccezioni custom +- [ ] definire almeno una eccezione per classe/modulo (es. PaletteError, VRAMError) +- [ ] sollevarla sempre con un messaggio che dice COSA è andato storto e QUALE valore ha causato il problema diff --git a/renderer/classes.py b/renderer/classes.py new file mode 100644 index 0000000..1b17946 --- /dev/null +++ b/renderer/classes.py @@ -0,0 +1,422 @@ +import numpy as np +import json +from PIL import Image + + +class PaletteError(Exception): + pass + + +class Palette: + def __init__(self, path): + self.__path = path + self.data = self._load_json() + + # Reads and validates a palette JSON file. + # Input: self.__path — path to a JSON file containing a list of 16 [R, G, B] colors. + # Output: np.ndarray of shape (16, 3) dtype uint8 with the palette colors. + def _load_json(self): + try: + with open(self.__path) as f: + raw = json.load(f) + except FileNotFoundError as e: + raise FileNotFoundError(f"palette file not found: {self.__path!r}") from e + except json.JSONDecodeError as e: + raise PaletteError(f"file JSON not valid: {e}") from e + if len(raw) != 16: + raise PaletteError(f"palette must contain 16 colors, trovati {len(raw)}") + for i, color in enumerate(raw): + if len(color) != 3: + raise PaletteError( + f"color {i}: must contain 3 component for RGB, found {len(color)}" + ) + for component in color: + if not isinstance(component, int) or isinstance(component, bool): + raise PaletteError( + f"color {i}: component must be int, got {type(component).__name__}" + ) + if not 0 <= component <= 255: + raise PaletteError( + f"color {i}: value {component} out of bounds [0, 255]" + ) + return np.array(raw, np.uint8) + + # Returns an unambiguous string representation of the palette. + # Input: none. + # Output: str. + def __repr__(self): + return f"Palette(path={self.__path!r})" + + # Prints the full palette to stdout. + # Input: none. + # Output: none. + def print_palette(self): + print(self.data) + + # Returns the RGB color at the given palette index. + # Input: idx — integer in [0, 15]. + # Output: np.ndarray of shape (3,) dtype uint8 with [R, G, B] values. + def __getitem__(self, idx): + if not isinstance(idx, int): + raise PaletteError(f"index must be int, got {type(idx).__name__}") + if not 0 <= idx <= 15: + raise PaletteError(f"index {idx} out of range [0, 15]") + return self.data[idx] + + +class VRAMError(Exception): + pass + + +class VirtualVRAM: + def __init__(self, path_t, path_s): + self.__path_tiles = path_t + self.__path_sprites = path_s + self.__tiles, self.__sprites = self._load_bin() + self._tiles_mx = self._decode(self.__tiles) + self._sprites_mx = self._decode(self.__sprites) + + # Reads the tile sheet and sprite sheet binary files. + # Input: self.__path_tiles, self.__path_sprites — paths to nibble-packed binary files. + # Output: tuple of two bytes objects (tiles_raw, sprites_raw). + def _load_bin(self): + try: + with open(self.__path_tiles, "rb") as ftile: + tiles = ftile.read() + except FileNotFoundError as e: + raise FileNotFoundError( + f"tiles file not found: {self.__path_tiles!r}" + ) from e + except OSError as e: + raise VRAMError(f"cannot open {self.__path_tiles!r}: {e}") from e + try: + with open(self.__path_sprites, "rb") as fsprite: + sprites = fsprite.read() + except FileNotFoundError as e: + raise FileNotFoundError( + f"sprites file not found: {self.__path_sprites!r}" + ) from e + except OSError as e: + raise VRAMError(f"cannot open {self.__path_sprites!r}: {e}") from e + return tiles, sprites + + # Decodes a nibble-packed binary buffer into a 256x256 matrix of palette indexes. + # Input: raw — bytes of exactly 32768 bytes (2 pixels per byte, high nibble first). + # Output: np.ndarray of shape (256, 256) dtype uint8 with palette indexes 0-15. + def _decode(self, raw): + if len(raw) != 32768: + raise VRAMError(f"invalid file size: expected 32768 bytes, got {len(raw)}") + data = np.frombuffer(raw, dtype=np.uint8) + return np.stack([data >> 4, data & 0x0F], axis=1).ravel().reshape(256, 256) + + # Returns the tile at the given index as a palette-index matrix. + # Input: idx — integer in [0, 63]. + # Output: np.ndarray of shape (32, 32) dtype uint8 with palette indexes 0-15. + def get_tile(self, idx): + if not isinstance(idx, int): + raise VRAMError(f"index must be int, got {type(idx).__name__}") + if not 0 <= idx <= 63: + raise VRAMError(f"index must be in [0, 63], got {idx}") + row = idx // 8 + col = idx % 8 + return self._tiles_mx[row * 32 : row * 32 + 32, col * 32 : col * 32 + 32] + + # Returns the sprite at the given index as a palette-index matrix. + # Input: idx — integer in [0, 15]. + # Output: np.ndarray of shape (64, 64) dtype uint8 with palette indexes 0-15. + def get_sprite(self, idx): + if not isinstance(idx, int): + raise VRAMError(f"index must be int, got {type(idx).__name__}") + if not 0 <= idx <= 15: + raise VRAMError(f"index must be in [0, 15], got {idx}") + row = idx // 4 + col = idx % 4 + return self._sprites_mx[row * 64 : row * 64 + 64, col * 64 : col * 64 + 64] + + def __repr__(self): + return ( + f"VirtualVRAM(path_t={self.__path_tiles!r}, path_s={self.__path_sprites!r})" + ) + + +class SceneError(Exception): + pass + + +class SceneParser: + def __init__(self, path): + self.__path = path + raw = self._load_descriptor() + self.transparent_index, self.tile_map, self.sprites = self._validate(raw) + + # Reads the scene JSON file. + # Input: self.__path — path to a JSON file describing the scene. + # Output: dict with the raw parsed JSON content. + def _load_descriptor(self): + try: + with open(self.__path) as f: + raw = json.load(f) + except FileNotFoundError as e: + raise FileNotFoundError(f"scene file not found: {self.__path!r}") from e + except json.JSONDecodeError as e: + raise SceneError(f"file JSON not valid: {e}") from e + for sprite in raw.get("sprites", []): + if "flip_h" in sprite: + sprite["flip_x"] = sprite.pop("flip_h") + if "flip_v" in sprite: + sprite["flip_y"] = sprite.pop("flip_v") + return raw + + # Validates the raw scene dict and returns typed, checked fields. + # Input: raw — dict from _load_descriptor. + # Output: tuple (transparent_index: int, tile_map: np.ndarray (15,20) uint8, sprites: list of dict). + def _validate(self, raw): + for key in ("transparent_index", "tile_map", "sprites"): + if key not in raw: + raise SceneError(f"missing key: {key!r}") + transparent_idx = raw["transparent_index"] + tile_map = raw["tile_map"] + sprites = raw["sprites"] + if not isinstance(transparent_idx, int) or isinstance(transparent_idx, bool): + raise SceneError( + f"transparent_index must be int, got {type(transparent_idx).__name__}" + ) + if not 0 <= transparent_idx <= 15: + raise SceneError( + f"transparent_index must be in [0, 15], got {transparent_idx}" + ) + if len(tile_map) != 15: + raise SceneError(f"tile_map must have 15 rows, got {len(tile_map)}") + for i, row in enumerate(tile_map): + if len(row) != 20: + raise SceneError( + f"tile_map row {i} must have 20 columns, got {len(row)}" + ) + for j, v in enumerate(row): + if not isinstance(v, int) or isinstance(v, bool): + raise SceneError( + f"tile_map[{i}][{j}] must be int, got {type(v).__name__}" + ) + if not 0 <= v <= 63: + raise SceneError( + f"tile_map[{i}][{j}] value {v} out of range [0, 63]" + ) + if not isinstance(sprites, list): + raise SceneError(f"sprites must be a list, got {type(sprites).__name__}") + for i, sprite in enumerate(sprites): + for key in ("id", "x", "y", "flip_x", "flip_y", "rotation"): + if key not in sprite: + raise SceneError(f"sprite {i}: missing key {key!r}") + if not isinstance(sprite["id"], int) or isinstance(sprite["id"], bool): + raise SceneError( + f"sprite {i}: id must be int, got {type(sprite['id']).__name__}" + ) + if not 0 <= sprite["id"] <= 15: + raise SceneError( + f"sprite {i}: id must be in [0, 15], got {sprite['id']}" + ) + if not isinstance(sprite["x"], int) or isinstance(sprite["x"], bool): + raise SceneError( + f"sprite {i}: x must be int, got {type(sprite['x']).__name__}" + ) + if not isinstance(sprite["y"], int) or isinstance(sprite["y"], bool): + raise SceneError( + f"sprite {i}: y must be int, got {type(sprite['y']).__name__}" + ) + if not isinstance(sprite["flip_x"], bool): + raise SceneError( + f"sprite {i}: flip_x must be bool, got {type(sprite['flip_x']).__name__}" + ) + if not isinstance(sprite["flip_y"], bool): + raise SceneError( + f"sprite {i}: flip_y must be bool, got {type(sprite['flip_y']).__name__}" + ) + if not isinstance(sprite["rotation"], int) or isinstance(sprite["rotation"], bool): + raise SceneError( + f"sprite {i}: rotation must be int, got {type(sprite['rotation']).__name__}" + ) + if sprite["rotation"] not in {0, 90, 180, 270}: + raise SceneError( + f"sprite {i}: rotation must be in {{0, 90, 180, 270}}, got {sprite['rotation']}" + ) + return transparent_idx, np.array(tile_map, np.uint8), sprites + + # Returns an unambiguous string representation of the scene parser. + # Input: none. + # Output: str. + def __repr__(self): + return f"SceneParser(path={self.__path!r})" + + +class BlitterException(Exception): + pass + + +FRAME_W = 640 +FRAME_H = 480 + + +class Blitter: + def __init__(self, vram, asset_type, idx, transparent_index, buffer): + self.__vram = vram + self.__asset_type, self.__position, self.transparent_index = self._validate( + asset_type, idx, transparent_index + ) + self._buffer = buffer + + # Validates asset_type, idx and transparent_index. + # Input: asset_type — "tile" or "sprite"; idx — int index into the respective sheet; transparent_index — palette index 0-15. + # Output: tuple (asset_type: str, idx: int, transparent_index: int). + def _validate(self, asset_type, idx, transparent_index): + if asset_type not in {"tile", "sprite"}: + raise BlitterException( + f"asset_type must be 'tile' or 'sprite', got {asset_type!r}" + ) + if not isinstance(idx, int): + raise BlitterException(f"idx must be int, got {type(idx).__name__}") + if asset_type == "tile" and not 0 <= idx <= 63: + raise BlitterException(f"tile idx must be in [0, 63], got {idx}") + if asset_type == "sprite" and not 0 <= idx <= 15: + raise BlitterException(f"sprite idx must be in [0, 15], got {idx}") + if not isinstance(transparent_index, int): + raise BlitterException( + f"transparent_index must be int, got {type(transparent_index).__name__}" + ) + if not 0 <= transparent_index <= 15: + raise BlitterException( + f"transparent_index must be in [0, 15], got {transparent_index}" + ) + return asset_type, idx, transparent_index + + # Blits the tile at self.__position onto the frame buffer at the given tilemap cell. + # Input: row — int in [0, 14]; col — int in [0, 19]. + # Output: none (writes to self._buffer in place). + def blit_tile(self, row, col): + if not isinstance(row, int): + raise BlitterException(f"row type must be int, got {type(row).__name__}") + if not isinstance(col, int): + raise BlitterException(f"col type must be int, got {type(col).__name__}") + if not 0 <= row <= 14: + raise BlitterException( + f"inserted row not valid, must be in [0,14], got {row}" + ) + if not 0 <= col <= 19: + raise BlitterException( + f"inserted col not valid, must be in [0,19], got {col}" + ) + tile_buf = self.__vram.get_tile(self.__position).copy() + self._buffer[row * 32 : row * 32 + 32, col * 32 : col * 32 + 32] = tile_buf + + # Applies flip and rotation transformations to a sprite matrix. + # Input: sprite_matrix — np.ndarray (64, 64) uint8; flip_x, flip_y — bool; rotation — 0/90/180/270. + # Output: np.ndarray (64, 64) uint8 transformed. + def _transform(self, sprite_matrix, flip_x, flip_y, rotation): + m = sprite_matrix + if flip_x: + m = np.fliplr(m) + if flip_y: + m = np.flipud(m) + if rotation != 0: + m = np.rot90(m, rotation // 90) + return m + + # Computes valid src/dst slice pairs to copy a 64x64 sprite at (x, y) into the frame buffer. + # Input: x, y — top-left position of the sprite in the frame buffer (can be negative). + # Output: tuple (dst, src) where each is a tuple of two slice objects (rows, cols). + # A sprite fully off-screen yields an empty destination; that case is returned as + # empty slices, since otherwise the negative width would produce inconsistent src + # slices (e.g. slice(0, -60) is not empty in numpy) and crash blit_sprite. + def _clip(self, x, y): + dst_r0 = max(0, y) + dst_r1 = min(FRAME_H, y + 64) + dst_c0 = max(0, x) + dst_c1 = min(FRAME_W, x + 64) + if dst_r1 <= dst_r0 or dst_c1 <= dst_c0: + empty = (slice(0, 0), slice(0, 0)) + return empty, empty + src_r0 = max(0, -y) + src_r1 = src_r0 + (dst_r1 - dst_r0) + src_c0 = max(0, -x) + src_c1 = src_c0 + (dst_c1 - dst_c0) + return (slice(dst_r0, dst_r1), slice(dst_c0, dst_c1)), ( + slice(src_r0, src_r1), + slice(src_c0, src_c1), + ) + + # Blits the sprite at self.__position onto the frame buffer with transformations and transparency. + # Input: x, y — top-left position in the frame buffer (can be negative); flip_x, flip_y — bool; rotation — 0/90/180/270. + # Output: none (writes to self._buffer in place). + def blit_sprite(self, x, y, flip_x, flip_y, rotation): + sprite_buf = self.__vram.get_sprite(self.__position).copy() + sprite_buf = self._transform(sprite_buf, flip_x, flip_y, rotation) + mask = sprite_buf != self.transparent_index + dst, src = self._clip(x, y) + self._buffer[dst][mask[src]] = sprite_buf[src][mask[src]] + + def __repr__(self): + return f"Blitter(asset_type={self.__asset_type!r}, position={self.__position})" + + +class RenderingException(Exception): + pass + + +class RenderingPipeline: + def __init__(self, palette_path, scene_path, tiles_path, sprites_path, output_path): + self._palette_path = palette_path + self._scene_path = scene_path + self._tiles_path = tiles_path + self._sprites_path = sprites_path + self._output_path = output_path + + # Returns an unambiguous string representation of the rendering pipeline. + # Input: none. + # Output: str. + def __repr__(self): + return ( + f"RenderingPipeline(palette_path={self._palette_path!r}, " + f"scene_path={self._scene_path!r}, tiles_path={self._tiles_path!r}, " + f"sprites_path={self._sprites_path!r}, output_path={self._output_path!r})" + ) + + # Runs the full pipeline: compose tiles and sprites onto the buffer, then export. + # Input: none. + # Output: none. + def render(self): + buf = RenderingPipeline.get_buf() + self._compose(buf) + self._export(buf) + + # Fills buf with all tiles from tile_map, then all sprites in scene order. + # Input: buf — np.ndarray (FRAME_H, FRAME_W) uint8, written in place. + # Output: none. + def _compose(self, buf): + vr = VirtualVRAM(self._tiles_path, self._sprites_path) + sp = SceneParser(self._scene_path) + for r, row in enumerate(sp.tile_map): + for c, tile_id in enumerate(row): + Blitter(vr, "tile", int(tile_id), sp.transparent_index, buf).blit_tile(r, c) + for sprite in sp.sprites: + Blitter(vr, "sprite", sprite["id"], sp.transparent_index, buf).blit_sprite( + sprite["x"], sprite["y"], sprite["flip_x"], sprite["flip_y"], sprite["rotation"] + ) + + # Maps palette indexes in buf to RGB and saves the result as PNG. + # Input: buf — np.ndarray (FRAME_H, FRAME_W) uint8 with palette indexes. + # Output: none (writes file to self._output_path). + def _export(self, buf): + palette = Palette(self._palette_path) + img = Image.fromarray(palette.data[buf]) + try: + img.save(self._output_path) + except (OSError, ValueError) as e: + raise RenderingException( + f"cannot save output PNG to {self._output_path!r}: {e}" + ) from e + + # Creates and returns a blank frame buffer. + # Input: none. + # Output: np.ndarray of shape (FRAME_H, FRAME_W) dtype uint8, all zeros. + @classmethod + def get_buf(cls): + return np.zeros((FRAME_H, FRAME_W), dtype=np.uint8) diff --git a/renderer/input/palette.json b/renderer/input/palette.json new file mode 100644 index 0000000..8efda84 --- /dev/null +++ b/renderer/input/palette.json @@ -0,0 +1,18 @@ +[ + [255, 0, 255], + [136, 208, 255], + [72, 144, 232], + [255, 255, 255], + [232, 248, 255], + [160, 232, 248], + [96, 168, 200], + [24, 48, 88], + [56, 88, 144], + [248, 240, 216], + [248, 200, 80], + [232, 136, 56], + [184, 136, 80], + [120, 80, 40], + [72, 128, 96], + [56, 72, 88] +] diff --git a/renderer/input/reference.png b/renderer/input/reference.png new file mode 100644 index 0000000..c76e455 Binary files /dev/null and b/renderer/input/reference.png differ diff --git a/renderer/input/scene.json b/renderer/input/scene.json new file mode 100644 index 0000000..6cb066a --- /dev/null +++ b/renderer/input/scene.json @@ -0,0 +1,62 @@ +{ + "transparent_index": 0, + "tile_map": [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 2, 3, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 1, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 6, 6, 6, 7, 0, 0, 6, 6, 6, 6, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 8, 0, 0], + [0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 0, 0, 9, 0, 0], + [0, 0, 0, 0, 0, 7, 7, 0, 0, 0, 0, 0, 0, 0, 11, 0, 0, 0, 0, 0], + [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4], + [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] + ], + "sprites": [ + { + "id": 0, + "x": 128, + "y": 352, + "flip_h": false, + "flip_v": false, + "rotation": 0 + }, + { + "id": 1, + "x": 208, + "y": 248, + "flip_h": false, + "flip_v": false, + "rotation": 0 + }, + { + "id": 1, + "x": 336, + "y": 152, + "flip_h": true, + "flip_v": false, + "rotation": 0 + }, + { + "id": 2, + "x": 544, + "y": 352, + "flip_h": false, + "flip_v": false, + "rotation": 0 + }, + { + "id": 3, + "x": 416, + "y": 352, + "flip_h": false, + "flip_v": false, + "rotation": 0 + } + ] +} diff --git a/renderer/input/sprites.bin b/renderer/input/sprites.bin new file mode 100644 index 0000000..0910a3d Binary files /dev/null and b/renderer/input/sprites.bin differ diff --git a/renderer/input/sprites.png b/renderer/input/sprites.png new file mode 100644 index 0000000..309bb71 Binary files /dev/null and b/renderer/input/sprites.png differ diff --git a/renderer/input/tiles.bin b/renderer/input/tiles.bin new file mode 100644 index 0000000..727cf62 --- /dev/null +++ b/renderer/input/tiles.bin @@ -0,0 +1 @@ +DDDDDDDDDDDDDDDDÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿDDDDDDDDDDDDDDDDÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿDDDDDDDDDDDDDDDDÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿDDDDDDDDDDDDDDDDÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ3331DDDDDDDDDDDDDDDDÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿffffffffffaÝÝÝÝÝÝÝÝÝÝÝÝ333331DDDDDDDDDDDDDDDDÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿeUUUUUUUUUUVÝÌÌÌÌÌÌÌÌÌÌÝ3333333333DDDDDDDDDDDDDDDDÿÿÿßÿÿÿÿÿÿÿÿÿÿÿÿUUUUUUUUUUUUaÜÜÌÌÌÌÌÌÌÌÍÍ3333333333313333DDDDDDDDDDDDDDDDÿÿýÝÿÿÿÿÿÿÿÿÿÿÿÿeUUUUUUUUUUUUVÜÍÌÌÌÌÌÌÌÌÜÍ33333133333333333331DDDDDDDDDDDDDDDDÿÿÿßÿÿÿÿÿßÿÿÿÿÿÿeDDDDDDDDDDDDVÜÌÜÌÌÌÌÌÌÍÌÍ3333333333333333333333DDDDDDDDDDDDDDDDÿÿÿÿÿÿÿÿýÝÿÿÿÿÿÿeUUUUUUUUUUUUVÜÌÍÌÌÌÌÌÌÜÌÍ333333333333333331333333ffVfffVfffVfffVfÿÿÿÿÿÿÿÿÿßÿÿÿÿÿÿeUUUUUUUUUUUUVÜÌÌÜÌÌÌÌÍÌÌÍ33333331333333333333333333333ÿÿ_ÿÿÿ_ÿÿÿ_ÿÿÿ_ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿeUUUUUUUUUUUUVÜÌÌÍÌÌÌÌÜÌÌÍ333333331333333333333331333333333ÿõÿÿÿõÿÿÿõÿÿÿõÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿeU5UUUUUUUUUUVÜÌÌÌÝÝÝÝÌÌÌÍ33333333313333333333333313333333331ÿõÿÿÿõÿÿÿõÿÿÿõÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿeUSUUUUUUUUUUVÜÌÌÌÝÌÌÝÌÌÌÍ333333333333333333333333333333333333ÿ_ÿÿÿ_ÿÿÿ_ÿÿÿ_ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿeUU3UUUU5UUUUVÜÌÌÌÜÜÍÍÌÌÌÍ3333333333313333333333333333333333333331ÿ_ÿÿÿ_ÿÿÿ_ÿÿÿ_ÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿeUUU5UUUSUUUUVÜÌÌÌÜÍÜÍÌÌÌÍ3333333333313333333333333333333333333331ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÝßÿÿÿeUUUSUUUU5UUUVÜÌÌÌÜÍÜÍÌÌÌÍ3333333333313333333333333333333333333331ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿeUUUU5UUUS5UUVÜÌÌÌÜÜÍÍÌÌÌÍ3333333333313333333333333331333333333331ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿeUUUUS5UUUSUUVÜÌÌÌÝÌÌÝÌÌÌÍ333333333331333333333333331333333333331ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿßÿÿÿÿÿÿÿÿÿÿeUUUUUSUUUU5UVÜÌÌÌÝÝÝÝÌÌÌÍ33333333333333333333333333333333333ÿÿÿßÿÿÿßÿÿÿýÿÿßÿÿÿÿÿýÝÿÿÿÿÿÿÿÿÿÿeUUUUUU5UUUUUVÜÌÌÍÌÌÌÌÜÌÌÍ333333333133333333133313333333331ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿßÿÿÿÿÿÿÿÿÿÿeUUUUUUUUUUUUVÜÌÌÜÌÌÌÌÍÌÌÍ33333333333333331333333333ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿeUUUUUUUUUUUUVÜÌÍÌÌÌÌÌÌÜÌÍ""""""""""""""""""""""""""""""""""""ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿeUUUUUUUUUUUUVÜÌÜÌÌÌÌÌÌÍÌÍ33333333331ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿßÿeUUUUUUUUUUUUVÜÍÌÌÌÌÌÌÌÌÜÍÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÝÿUUUUUUUUUUUUaÜÜÌÌÌÌÌÌÌÌÍÍÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿßÿeUUUUUUUUUUVÝÌÌÌÌÌÌÌÌÌÌÝÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿßÿÿÿÿÿÿÿÿÿÿÿÿÿffffffffffaÝÝÝÝÝÝÝÝÝÝÝÝÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÝÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿßÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿqffffffffçffffffffffaeUUUUUUVqçDDDDDDDDDDaeU5UUUUVG~îqDDDDDDDDDDaeU5UUUUVtDqîîçDDDDDDDDDDaeU5UUUUVDDGîîçDDDDDDDDDDaeU5UUUUVDDG~îîîqDDDDDDDDDDaeU5UUUUVtDDDqîîîîçDDDDDDDDDDaeU5UUUUVDDDDGîîîîçffffffffffaeU5UUUUV~îîîîîq~îîîîîqUUUUUUUUaeU5UUUUVîîîîîîçîîîîîîçUUUUUUUUaeU5UUUUV~îîîîîîîqîîîîîîçUUUUUUUUaeU5UUUUV~îîîîîîîq~îîîîîîîqUUUUUUUUaeU5UUUUVîîîîîîîîçîîîîîîîîçUUUUUUUUaeU5UUUUV~îîîîîîîîîqîîîîîîîîçUUUUUUUUaeU5UUUUVwwwîîîîçwww~îîîîîîîîîqUUUUUUUUaeU5UUUUV~îîîîîqîîîîîîîîîîçUUUUUUUUaeU5UUUUV~îîîîîqîîîîîîîîîîçUUUUUUUUaeU5UUUUVîîîîîîçwwww}ÝÝ×wwwwqUUUUUUUUaeU5UUUUV~îîîîîîîqÝÝÑUUUUUUUUaeU5UUUUV~îîîîîîîqÝÝÑUUUUUUUUaeU5UUUUVîîîîîîîîçÝÝÑUUUUUUUUaeU5UUUUV~îîîîîîîîîqÝÝÑUUUUUUUUaeU5UUUUVîîîîîîîîîîçÝÝÑUUUUUUUUaeU5UUUUVîîîîîîîîîîçÝÝÑUUUUUUUUaeU5UUUUV~îîîîîîîîîîîqÝÝÑUUUUUUUUaeU5UUUUVîîîîîîîîîîîîçÝÝÑUUUUUUUUaeU5UUUUVîîîîîîîîîîîîçÝÝÑUUUUUUUUaeU5UUUUVwwwwwwwwwwwwwwqÝÝÑeUUUUUUVeU5UUUUVÝÝÑfUUUUUUfeU5UUUUVÝÝÑffffffeUUUUUUVÝÝÑffffffff \ No newline at end of file diff --git a/renderer/input/tiles.png b/renderer/input/tiles.png new file mode 100644 index 0000000..fa62697 Binary files /dev/null and b/renderer/input/tiles.png differ diff --git a/renderer/main.py b/renderer/main.py new file mode 100644 index 0000000..529c6b4 --- /dev/null +++ b/renderer/main.py @@ -0,0 +1,19 @@ +import argparse +from classes import RenderingPipeline + + +def main(): + parser = argparse.ArgumentParser(description="PAP pixel art renderer") + parser.add_argument("palette", help="path to palette.json") + parser.add_argument("scene", help="path to scene.json") + parser.add_argument("tiles", help="path to tiles.bin") + parser.add_argument("sprites", help="path to sprites.bin") + parser.add_argument("output", help="path to output PNG") + args = parser.parse_args() + RenderingPipeline( + args.palette, args.scene, args.tiles, args.sprites, args.output + ).render() + + +if __name__ == "__main__": + main() diff --git a/renderer/output/.gitkeep b/renderer/output/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/renderer/output/example.png b/renderer/output/example.png new file mode 100644 index 0000000..c76e455 Binary files /dev/null and b/renderer/output/example.png differ diff --git a/renderer/project_python.pdf b/renderer/project_python.pdf new file mode 100644 index 0000000..28519c8 Binary files /dev/null and b/renderer/project_python.pdf differ diff --git a/renderer/pyproject.toml b/renderer/pyproject.toml new file mode 100644 index 0000000..db7a8c8 --- /dev/null +++ b/renderer/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "pap-render" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "numpy>=2.4.6", + "pillow>=12.2.0", + "pytest>=9.0.3", +] + +[tool.pytest.ini_options] +python_files = ["test_*.py", "tests.py"] diff --git a/renderer/test_data/palette_ok.json b/renderer/test_data/palette_ok.json new file mode 100644 index 0000000..7531050 --- /dev/null +++ b/renderer/test_data/palette_ok.json @@ -0,0 +1,18 @@ +[ + [0, 0, 0 ], + [255, 0, 0 ], + [0, 255, 0 ], + [0, 0, 255], + [255, 255, 0 ], + [0, 255, 255], + [255, 0, 255], + [255, 255, 255], + [128, 0, 0 ], + [0, 128, 0 ], + [0, 0, 128], + [128, 128, 0 ], + [0, 128, 128], + [128, 0, 128], + [128, 128, 128], + [255, 128, 0 ] +] diff --git a/renderer/test_data/palette_wrong_count.json b/renderer/test_data/palette_wrong_count.json new file mode 100644 index 0000000..9280d30 --- /dev/null +++ b/renderer/test_data/palette_wrong_count.json @@ -0,0 +1,5 @@ +[ + [0, 0, 0 ], + [255, 0, 0 ], + [0, 255, 0 ] +] diff --git a/renderer/test_data/palette_wrong_value.json b/renderer/test_data/palette_wrong_value.json new file mode 100644 index 0000000..ec437e2 --- /dev/null +++ b/renderer/test_data/palette_wrong_value.json @@ -0,0 +1,18 @@ +[ + [0, 0, 0 ], + [255, 0, 0 ], + [0, 255, 0 ], + [0, 0, 300], + [255, 255, 0 ], + [0, 255, 255], + [255, 0, 255], + [255, 255, 255], + [128, 0, 0 ], + [0, 128, 0 ], + [0, 0, 128], + [128, 128, 0 ], + [0, 128, 128], + [128, 0, 128], + [128, 128, 128], + [255, 128, 0 ] +] diff --git a/renderer/tests.py b/renderer/tests.py new file mode 100644 index 0000000..6ad9c86 --- /dev/null +++ b/renderer/tests.py @@ -0,0 +1,1151 @@ +# %% +import sys +import pytest +import json +import numpy as np +from pathlib import Path +from PIL import Image as PILImage +from classes import Palette, PaletteError, VirtualVRAM, VRAMError, SceneParser, SceneError, Blitter, BlitterException, RenderingPipeline, RenderingException, FRAME_W, FRAME_H +from main import main + +VALID = "test_data/palette_ok.json" + + +@pytest.fixture +def test_dir(tmp_path): + # File generati dai test finiscono nella tmp dir per-test di pytest, + # non nella cartella del repo. + return tmp_path + + +def make_tmp(base_dir, data): + f = base_dir / "palette.json" + f.write_text(json.dumps(data)) + return str(f) + + +# -- happy path --------------------------------------------------------------- + +def test_load_ok(): + p = Palette(VALID) + assert p.data.shape == (16, 3) + assert p.data.dtype == np.uint8 + +def test_getitem_first(): + p = Palette(VALID) + assert list(p[0]) == [0, 0, 0] + +def test_getitem_last(): + p = Palette(VALID) + assert list(p[15]) == [255, 128, 0] + +def test_boundary_values(test_dir): + data = [([0, 0, 0] if i % 2 == 0 else [255, 255, 255]) for i in range(16)] + p = Palette(make_tmp(test_dir, data)) + assert list(p[0]) == [0, 0, 0] + assert list(p[1]) == [255, 255, 255] + + +# -- file errors -------------------------------------------------------------- + +def test_file_not_found(): + with pytest.raises(FileNotFoundError): + Palette("non_esiste.json") + +def test_invalid_json(test_dir): + f = test_dir / "palette.json" + f.write_text("questo non e json {{{") + with pytest.raises(PaletteError): + Palette(str(f)) + + +# -- wrong color count -------------------------------------------------------- + +def test_too_few_colors(test_dir): + data = [[0, 0, 0]] * 3 + with pytest.raises(PaletteError): + Palette(make_tmp(test_dir, data)) + +def test_too_many_colors(test_dir): + data = [[0, 0, 0]] * 17 + with pytest.raises(PaletteError): + Palette(make_tmp(test_dir, data)) + +def test_empty_palette(test_dir): + with pytest.raises(PaletteError): + Palette(make_tmp(test_dir, [])) + + +# -- wrong color format ------------------------------------------------------- + +def test_color_too_few_components(test_dir): + data = [[0, 0]] + [[0, 0, 0]] * 15 + with pytest.raises(PaletteError): + Palette(make_tmp(test_dir, data)) + +def test_color_too_many_components(test_dir): + data = [[0, 0, 0, 0]] + [[0, 0, 0]] * 15 + with pytest.raises(PaletteError): + Palette(make_tmp(test_dir, data)) + + +# -- out of range values ------------------------------------------------------ + +def test_value_above_255(test_dir): + data = [[300, 0, 0]] + [[0, 0, 0]] * 15 + with pytest.raises(PaletteError): + Palette(make_tmp(test_dir, data)) + +def test_value_negative(test_dir): + data = [[-1, 0, 0]] + [[0, 0, 0]] * 15 + with pytest.raises(PaletteError): + Palette(make_tmp(test_dir, data)) + +# C4 regression: a color component must be an integer (the PDF says integers). +def test_value_float_component(test_dir): + data = [[255.5, 0, 0]] + [[0, 0, 0]] * 15 + with pytest.raises(PaletteError): + Palette(make_tmp(test_dir, data)) + +def test_value_bool_component(test_dir): + data = [[True, 0, 0]] + [[0, 0, 0]] * 15 + with pytest.raises(PaletteError): + Palette(make_tmp(test_dir, data)) + +def test_value_exact_255(test_dir): + data = [[255, 255, 255]] * 16 + p = Palette(make_tmp(test_dir, data)) + assert list(p[0]) == [255, 255, 255] + + +# -- __getitem__ bounds ------------------------------------------------------- + +def test_getitem_out_of_range_high(): + p = Palette(VALID) + with pytest.raises(PaletteError): + p[16] + +def test_getitem_out_of_range_low(): + p = Palette(VALID) + with pytest.raises(PaletteError): + p[-1] + + +# -- VirtualVRAM helpers ------------------------------------------------------ + +def make_tmp_bin(base_dir, data, name="sheet.bin"): + f = base_dir / name + f.write_bytes(data) + return str(f) + + +# -- VirtualVRAM happy path --------------------------------------------------- + +def test_vram_load_ok(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram._tiles_mx.shape == (256, 256) + assert vram._sprites_mx.shape == (256, 256) + assert vram._tiles_mx.dtype == np.uint8 + assert vram._sprites_mx.dtype == np.uint8 + +def test_vram_decode_all_zeros(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram._tiles_mx.max() == 0 + +def test_vram_decode_all_ff(test_dir): + t = make_tmp_bin(test_dir, bytes([0xFF] * 32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram._tiles_mx.max() == 15 + assert vram._tiles_mx.min() == 15 + +def test_vram_decode_nibbles(test_dir): + # 0xAB → high nibble = 10 (primo pixel), low nibble = 11 (secondo pixel) + data = bytes([0xAB]) + bytes(32767) + t = make_tmp_bin(test_dir, data, "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram._tiles_mx[0, 0] == 10 + assert vram._tiles_mx[0, 1] == 11 + + +# -- VirtualVRAM file errors -------------------------------------------------- + +def test_vram_tiles_not_found(test_dir): + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + with pytest.raises(FileNotFoundError): + VirtualVRAM("non_esiste.bin", s) + +def test_vram_sprites_not_found(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + with pytest.raises(FileNotFoundError): + VirtualVRAM(t, "non_esiste.bin") + + +# -- VirtualVRAM wrong size --------------------------------------------------- + +def test_vram_tiles_wrong_size(test_dir): + t = make_tmp_bin(test_dir, bytes(100), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + with pytest.raises(VRAMError): + VirtualVRAM(t, s) + +def test_vram_sprites_wrong_size(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(100), "sprites.bin") + with pytest.raises(VRAMError): + VirtualVRAM(t, s) + + +# -- get_tile / get_sprite sheet builders ------------------------------------- + +def make_tile_sheet(tile_id, fill_byte=0xFF): + data = bytearray(32768) + row_start = (tile_id // 8) * 32 + col_start = (tile_id % 8) * 32 + for r in range(32): + offset = (row_start + r) * 128 + col_start // 2 + for b in range(16): + data[offset + b] = fill_byte + return bytes(data) + +def make_sprite_sheet(sprite_id, fill_byte=0xFF): + data = bytearray(32768) + row_start = (sprite_id // 4) * 64 + col_start = (sprite_id % 4) * 64 + for r in range(64): + offset = (row_start + r) * 128 + col_start // 2 + for b in range(32): + data[offset + b] = fill_byte + return bytes(data) + + +# -- get_tile happy path ------------------------------------------------------ + +def test_get_tile_shape(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram.get_tile(0).shape == (32, 32) + assert vram.get_tile(0).dtype == np.uint8 + +def test_get_tile_first(test_dir): + t = make_tmp_bin(test_dir, make_tile_sheet(0), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram.get_tile(0).min() == 15 + assert vram.get_tile(0).max() == 15 + +def test_get_tile_last(test_dir): + t = make_tmp_bin(test_dir, make_tile_sheet(63), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram.get_tile(63).min() == 15 + assert vram.get_tile(63).max() == 15 + +def test_get_tile_isolation(test_dir): + t = make_tmp_bin(test_dir, make_tile_sheet(5), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram.get_tile(5).min() == 15 + assert vram.get_tile(0).max() == 0 + + +# -- get_tile error path ------------------------------------------------------ + +def test_get_tile_not_int(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + with pytest.raises(VRAMError): + vram.get_tile("0") + +def test_get_tile_out_of_range_high(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + with pytest.raises(VRAMError): + vram.get_tile(64) + +def test_get_tile_out_of_range_low(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + with pytest.raises(VRAMError): + vram.get_tile(-1) + + +# -- get_sprite happy path ---------------------------------------------------- + +def test_get_sprite_shape(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram.get_sprite(0).shape == (64, 64) + assert vram.get_sprite(0).dtype == np.uint8 + +def test_get_sprite_first(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, make_sprite_sheet(0), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram.get_sprite(0).min() == 15 + assert vram.get_sprite(0).max() == 15 + +def test_get_sprite_last(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, make_sprite_sheet(15), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram.get_sprite(15).min() == 15 + assert vram.get_sprite(15).max() == 15 + +def test_get_sprite_isolation(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, make_sprite_sheet(7), "sprites.bin") + vram = VirtualVRAM(t, s) + assert vram.get_sprite(7).min() == 15 + assert vram.get_sprite(0).max() == 0 + + +# -- get_sprite error path ---------------------------------------------------- + +def test_get_sprite_not_int(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + with pytest.raises(VRAMError): + vram.get_sprite("0") + +def test_get_sprite_out_of_range_high(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + with pytest.raises(VRAMError): + vram.get_sprite(16) + +def test_get_sprite_out_of_range_low(test_dir): + t = make_tmp_bin(test_dir, bytes(32768), "tiles.bin") + s = make_tmp_bin(test_dir, bytes(32768), "sprites.bin") + vram = VirtualVRAM(t, s) + with pytest.raises(VRAMError): + vram.get_sprite(-1) + + +# -- SceneParser helpers ------------------------------------------------------ + +VALID_TILE_MAP = [[0] * 20 for _ in range(15)] +VALID_SPRITE = {"id": 0, "x": 0, "y": 0, "flip_x": False, "flip_y": False, "rotation": 0} + +def make_scene(base_dir, data, name="scene.json"): + f = base_dir / name + f.write_text(json.dumps(data)) + return str(f) + +def valid_scene(): + return { + "transparent_index": 0, + "tile_map": VALID_TILE_MAP, + "sprites": [VALID_SPRITE], + } + + +# -- SceneParser happy path --------------------------------------------------- + +def test_scene_load_ok(test_dir): + p = SceneParser(make_scene(test_dir, valid_scene())) + assert p.transparent_index == 0 + assert p.tile_map.shape == (15, 20) + assert p.tile_map.dtype == np.uint8 + assert isinstance(p.sprites, list) + +def test_scene_transparent_index_boundary(test_dir): + data = valid_scene() + data["transparent_index"] = 15 + p = SceneParser(make_scene(test_dir, data)) + assert p.transparent_index == 15 + +def test_scene_empty_sprites(test_dir): + data = valid_scene() + data["sprites"] = [] + p = SceneParser(make_scene(test_dir, data)) + assert p.sprites == [] + +def test_scene_sprite_fields(test_dir): + p = SceneParser(make_scene(test_dir, valid_scene())) + s = p.sprites[0] + assert s["id"] == 0 + assert s["x"] == 0 + assert s["y"] == 0 + assert s["flip_x"] is False + assert s["flip_y"] is False + assert s["rotation"] == 0 + + +# -- SceneParser file errors -------------------------------------------------- + +def test_scene_file_not_found(): + with pytest.raises(FileNotFoundError): + SceneParser("non_esiste.json") + +def test_scene_invalid_json(test_dir): + f = test_dir / "scene.json" + f.write_text("questo non e json {{{") + with pytest.raises(SceneError): + SceneParser(str(f)) + + +# -- SceneParser missing keys ------------------------------------------------- + +def test_scene_missing_transparent_index(test_dir): + data = valid_scene() + del data["transparent_index"] + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_missing_tile_map(test_dir): + data = valid_scene() + del data["tile_map"] + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_missing_sprites(test_dir): + data = valid_scene() + del data["sprites"] + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + + +# -- SceneParser transparent_index errors ------------------------------------- + +def test_scene_transparent_index_not_int(test_dir): + data = valid_scene() + data["transparent_index"] = "0" + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_transparent_index_too_high(test_dir): + data = valid_scene() + data["transparent_index"] = 16 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_transparent_index_negative(test_dir): + data = valid_scene() + data["transparent_index"] = -1 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + + +# -- C2 regression: bool rejected where int is required ----------------------- +# In Python bool is a subclass of int, so a JSON `true` would pass isinstance int. + +def test_scene_transparent_index_bool(test_dir): + data = valid_scene() + data["transparent_index"] = True + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_tile_map_bool(test_dir): + data = valid_scene() + data["tile_map"] = [[0] * 20 for _ in range(15)] + data["tile_map"][0][0] = True + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_x_bool(test_dir): + data = valid_scene() + data["sprites"] = [{"id": 0, "x": True, "y": 0, + "flip_x": False, "flip_y": False, "rotation": 0}] + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_rotation_bool(test_dir): + data = valid_scene() + data["sprites"] = [{"id": 0, "x": 0, "y": 0, + "flip_x": False, "flip_y": False, "rotation": True}] + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + + +# -- SceneParser tile_map errors ---------------------------------------------- + +def test_scene_tile_map_wrong_rows(test_dir): + data = valid_scene() + data["tile_map"] = [[0] * 20 for _ in range(10)] + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_tile_map_wrong_cols(test_dir): + data = valid_scene() + data["tile_map"] = [[0] * 15 for _ in range(15)] + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_tile_map_value_too_high(test_dir): + data = valid_scene() + data["tile_map"][0][0] = 64 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_tile_map_value_negative(test_dir): + data = valid_scene() + data["tile_map"][0][0] = -1 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_tile_map_value_not_int(test_dir): + data = valid_scene() + data["tile_map"][0][0] = 1.5 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + + +# -- SceneParser sprite errors ------------------------------------------------ + +def test_scene_sprites_not_list(test_dir): + data = valid_scene() + data["sprites"] = "not a list" + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_missing_field(test_dir): + data = valid_scene() + del data["sprites"][0]["rotation"] + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_id_not_int(test_dir): + data = valid_scene() + data["sprites"][0]["id"] = "0" + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_id_too_high(test_dir): + data = valid_scene() + data["sprites"][0]["id"] = 16 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_id_negative(test_dir): + data = valid_scene() + data["sprites"][0]["id"] = -1 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_x_not_int(test_dir): + data = valid_scene() + data["sprites"][0]["x"] = 1.5 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_y_not_int(test_dir): + data = valid_scene() + data["sprites"][0]["y"] = "0" + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_flip_x_not_bool(test_dir): + data = valid_scene() + data["sprites"][0]["flip_x"] = 0 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_flip_y_not_bool(test_dir): + data = valid_scene() + data["sprites"][0]["flip_y"] = 1 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_rotation_not_int(test_dir): + data = valid_scene() + data["sprites"][0]["rotation"] = "90" + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_sprite_rotation_invalid(test_dir): + data = valid_scene() + data["sprites"][0]["rotation"] = 45 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + +def test_scene_tile_map_jagged(test_dir): + data = valid_scene() + data["tile_map"][5] = [0] * 15 + with pytest.raises(SceneError): + SceneParser(make_scene(test_dir, data)) + + +# -- Blitter helpers ---------------------------------------------------------- + +def make_vram(base_dir, tile_data=None, sprite_data=None): + t = make_tmp_bin(base_dir, tile_data or bytes(32768), "tiles.bin") + s = make_tmp_bin(base_dir, sprite_data or bytes(32768), "sprites.bin") + return VirtualVRAM(t, s) + + +# -- Blitter init / _validate ------------------------------------------------- + +def test_blitter_init_tile_ok(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "tile", 0, 0, RenderingPipeline.get_buf()) + assert b.transparent_index == 0 + assert b._buffer.shape == (FRAME_H, FRAME_W) + assert b._buffer.dtype == np.uint8 + +def test_blitter_init_sprite_ok(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 15, 7, RenderingPipeline.get_buf()) + assert b.transparent_index == 7 + +def test_blitter_invalid_asset_type(test_dir): + vram = make_vram(test_dir) + with pytest.raises(BlitterException): + Blitter(vram, "block", 0, 0, RenderingPipeline.get_buf()) + +def test_blitter_idx_not_int(test_dir): + vram = make_vram(test_dir) + with pytest.raises(BlitterException): + Blitter(vram, "tile", "0", 0, RenderingPipeline.get_buf()) + +def test_blitter_tile_idx_out_of_range_high(test_dir): + vram = make_vram(test_dir) + with pytest.raises(BlitterException): + Blitter(vram, "tile", 64, 0, RenderingPipeline.get_buf()) + +def test_blitter_tile_idx_out_of_range_low(test_dir): + vram = make_vram(test_dir) + with pytest.raises(BlitterException): + Blitter(vram, "tile", -1, 0, RenderingPipeline.get_buf()) + +def test_blitter_sprite_idx_out_of_range_high(test_dir): + vram = make_vram(test_dir) + with pytest.raises(BlitterException): + Blitter(vram, "sprite", 16, 0, RenderingPipeline.get_buf()) + +def test_blitter_sprite_idx_out_of_range_low(test_dir): + vram = make_vram(test_dir) + with pytest.raises(BlitterException): + Blitter(vram, "sprite", -1, 0, RenderingPipeline.get_buf()) + +def test_blitter_transparent_index_not_int(test_dir): + vram = make_vram(test_dir) + with pytest.raises(BlitterException): + Blitter(vram, "tile", 0, "0", RenderingPipeline.get_buf()) + +def test_blitter_transparent_index_too_high(test_dir): + vram = make_vram(test_dir) + with pytest.raises(BlitterException): + Blitter(vram, "tile", 0, 16, RenderingPipeline.get_buf()) + +def test_blitter_transparent_index_negative(test_dir): + vram = make_vram(test_dir) + with pytest.raises(BlitterException): + Blitter(vram, "tile", 0, -1, RenderingPipeline.get_buf()) + + +# -- blit_tile ---------------------------------------------------------------- + +def test_blit_tile_writes_to_buffer(test_dir): + vram = make_vram(test_dir, tile_data=make_tile_sheet(0, 0xFF)) + b = Blitter(vram, "tile", 0, 0, RenderingPipeline.get_buf()) + b.blit_tile(0, 0) + assert b._buffer[0:32, 0:32].min() == 15 + assert b._buffer[0:32, 0:32].max() == 15 + +def test_blit_tile_correct_position(test_dir): + vram = make_vram(test_dir, tile_data=make_tile_sheet(0, 0xFF)) + b = Blitter(vram, "tile", 0, 0, RenderingPipeline.get_buf()) + b.blit_tile(1, 2) + assert b._buffer[32:64, 64:96].min() == 15 + assert b._buffer[0:32, 0:32].max() == 0 + +def test_blit_tile_row_not_int(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "tile", 0, 0, RenderingPipeline.get_buf()) + with pytest.raises(BlitterException): + b.blit_tile("0", 0) + +def test_blit_tile_col_not_int(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "tile", 0, 0, RenderingPipeline.get_buf()) + with pytest.raises(BlitterException): + b.blit_tile(0, "0") + +def test_blit_tile_row_out_of_range_high(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "tile", 0, 0, RenderingPipeline.get_buf()) + with pytest.raises(BlitterException): + b.blit_tile(15, 0) + +def test_blit_tile_row_out_of_range_low(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "tile", 0, 0, RenderingPipeline.get_buf()) + with pytest.raises(BlitterException): + b.blit_tile(-1, 0) + +def test_blit_tile_col_out_of_range_high(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "tile", 0, 0, RenderingPipeline.get_buf()) + with pytest.raises(BlitterException): + b.blit_tile(0, 20) + +def test_blit_tile_col_out_of_range_low(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "tile", 0, 0, RenderingPipeline.get_buf()) + with pytest.raises(BlitterException): + b.blit_tile(0, -1) + + +# -- _transform --------------------------------------------------------------- + +def test_transform_identity(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + m = np.arange(64 * 64, dtype=np.uint8).reshape(64, 64) % 16 + assert np.array_equal(b._transform(m, False, False, 0), m) + +def test_transform_flip_x(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + m = np.arange(64 * 64, dtype=np.uint8).reshape(64, 64) % 16 + assert np.array_equal(b._transform(m, True, False, 0), np.fliplr(m)) + +def test_transform_flip_y(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + m = np.arange(64 * 64, dtype=np.uint8).reshape(64, 64) % 16 + assert np.array_equal(b._transform(m, False, True, 0), np.flipud(m)) + +def test_transform_rotation_90(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + m = np.arange(64 * 64, dtype=np.uint8).reshape(64, 64) % 16 + assert np.array_equal(b._transform(m, False, False, 90), np.rot90(m, 1)) + +def test_transform_rotation_180(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + m = np.arange(64 * 64, dtype=np.uint8).reshape(64, 64) % 16 + assert np.array_equal(b._transform(m, False, False, 180), np.rot90(m, 2)) + +def test_transform_rotation_270(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + m = np.arange(64 * 64, dtype=np.uint8).reshape(64, 64) % 16 + assert np.array_equal(b._transform(m, False, False, 270), np.rot90(m, 3)) + +def test_transform_flip_x_and_y(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + m = np.arange(64 * 64, dtype=np.uint8).reshape(64, 64) % 16 + assert np.array_equal(b._transform(m, True, True, 0), np.flipud(np.fliplr(m))) + + +# -- _clip -------------------------------------------------------------------- + +def test_clip_fully_inside(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + dst, src = b._clip(0, 0) + assert dst == (slice(0, 64), slice(0, 64)) + assert src == (slice(0, 64), slice(0, 64)) + +def test_clip_centered(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + dst, src = b._clip(100, 50) + assert dst == (slice(50, 114), slice(100, 164)) + assert src == (slice(0, 64), slice(0, 64)) + +def test_clip_top(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + dst, src = b._clip(0, -10) + assert dst == (slice(0, 54), slice(0, 64)) + assert src == (slice(10, 64), slice(0, 64)) + +def test_clip_left(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + dst, src = b._clip(-10, 0) + assert dst == (slice(0, 64), slice(0, 54)) + assert src == (slice(0, 64), slice(10, 64)) + +def test_clip_bottom(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + dst, src = b._clip(0, FRAME_H - 32) + assert dst == (slice(FRAME_H - 32, FRAME_H), slice(0, 64)) + assert src == (slice(0, 32), slice(0, 64)) + +def test_clip_right(test_dir): + vram = make_vram(test_dir) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + dst, src = b._clip(FRAME_W - 32, 0) + assert dst == (slice(0, 64), slice(FRAME_W - 32, FRAME_W)) + assert src == (slice(0, 64), slice(0, 32)) + + +# -- blit_sprite -------------------------------------------------------------- + +def test_blit_sprite_all_opaque(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(0, 0, False, False, 0) + assert b._buffer[0:64, 0:64].min() == 15 + assert b._buffer[0:64, 0:64].max() == 15 + +def test_blit_sprite_all_transparent(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0x00)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(0, 0, False, False, 0) + assert b._buffer[0:64, 0:64].max() == 0 + +def test_blit_sprite_correct_position(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(100, 50, False, False, 0) + assert b._buffer[50:114, 100:164].min() == 15 + assert b._buffer[0:50, 0:100].max() == 0 + +def test_blit_sprite_clipping_left(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(-32, 0, False, False, 0) + assert b._buffer[0:64, 0:32].min() == 15 + assert b._buffer[0:64, 32:64].max() == 0 + +def test_blit_sprite_mixed_transparency(test_dir): + # 0xF0 → pixels alternano 15 (opaco) e 0 (trasparente) + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xF0)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(0, 0, False, False, 0) + assert b._buffer[0, 0] == 15 + assert b._buffer[0, 1] == 0 + +def test_blit_sprite_clipping_top(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(0, -32, False, False, 0) + assert b._buffer[0:32, 0:64].min() == 15 + assert b._buffer[32:64, 0:64].max() == 0 + +def test_blit_sprite_clipping_right(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(FRAME_W - 32, 0, False, False, 0) + assert b._buffer[0:64, FRAME_W - 32:FRAME_W].min() == 15 + assert b._buffer[0:64, FRAME_W - 96:FRAME_W - 32].max() == 0 + +def test_blit_sprite_clipping_bottom(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(0, FRAME_H - 32, False, False, 0) + assert b._buffer[FRAME_H - 32:FRAME_H, 0:64].min() == 15 + assert b._buffer[FRAME_H - 96:FRAME_H - 32, 0:64].max() == 0 + +def test_blit_sprite_fully_outside_right(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(FRAME_W, 0, False, False, 0) + assert b._buffer.max() == 0 + +def test_blit_sprite_fully_outside_bottom(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(0, FRAME_H, False, False, 0) + assert b._buffer.max() == 0 + +def test_blit_sprite_fully_outside_left(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(-64, 0, False, False, 0) + assert b._buffer.max() == 0 + +def test_blit_sprite_fully_outside_top(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(0, -64, False, False, 0) + assert b._buffer.max() == 0 + +# C1 regression: sprite beyond the positive edge (x > FRAME_W, y > FRAME_H). +# Before the _clip fix the negative dst width produced inconsistent src slices +# -> IndexError. The exact-edge tests (FRAME_W/-64) did not catch it. +def test_blit_sprite_beyond_right_no_crash(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(FRAME_W + 60, 32, False, False, 0) + assert b._buffer.max() == 0 + +def test_blit_sprite_beyond_bottom_no_crash(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(32, FRAME_H + 20, False, False, 0) + assert b._buffer.max() == 0 + +def test_blit_sprite_far_left_no_crash(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(-100, 32, False, False, 0) + assert b._buffer.max() == 0 + +def test_blit_sprite_transform_and_clip(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(-32, 0, True, False, 90) + assert b._buffer[0:64, 0:32].min() == 15 + +def test_blit_sprite_z_order(test_dir): + vram = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0xFF)) + b = Blitter(vram, "sprite", 0, 0, RenderingPipeline.get_buf()) + b.blit_sprite(0, 0, False, False, 0) + # second blit with all-transparent sprite should not overwrite + vram2 = make_vram(test_dir, sprite_data=make_sprite_sheet(0, 0x00)) + b2 = Blitter(vram2, "sprite", 0, 0, b._buffer) + b2.blit_sprite(0, 0, False, False, 0) + assert b2._buffer[0:64, 0:64].min() == 15 + + +# -- RenderingPipeline helpers ------------------------------------------------ + +PIPELINE_PALETTE = [[i * 17, i * 17, i * 17] for i in range(16)] + + +def pack_matrix(matrix): + flat = matrix.ravel().astype(np.uint8) + return ((flat[0::2] << 4) | flat[1::2]).tobytes() + + +def make_pipeline_palette(base_dir): + f = base_dir / "palette.json" + f.write_text(json.dumps(PIPELINE_PALETTE)) + return str(f) + + +def make_pipeline_tiles(base_dir, tile0_index=1): + matrix = np.zeros((256, 256), dtype=np.uint8) + matrix[0:32, 0:32] = tile0_index + f = base_dir / "tiles.bin" + f.write_bytes(pack_matrix(matrix)) + return str(f) + + +def make_pipeline_sprites(base_dir, sprite0_index=2): + matrix = np.zeros((256, 256), dtype=np.uint8) + matrix[0:64, 0:64] = sprite0_index + f = base_dir / "sprites.bin" + f.write_bytes(pack_matrix(matrix)) + return str(f) + + +def make_pipeline_scene(base_dir, transparent_index=0, sprites=None): + data = { + "transparent_index": transparent_index, + "tile_map": [[0] * 20 for _ in range(15)], + "sprites": sprites or [], + } + f = base_dir / "scene.json" + f.write_text(json.dumps(data)) + return str(f) + + +def make_pipeline(base_dir, tile0_index=1, sprite0_index=2, transparent_index=0, sprites=None): + palette = make_pipeline_palette(base_dir) + tiles = make_pipeline_tiles(base_dir, tile0_index) + sprites_bin = make_pipeline_sprites(base_dir, sprite0_index) + scene = make_pipeline_scene(base_dir, transparent_index, sprites) + output = str(base_dir / "output.png") + return RenderingPipeline(palette, scene, tiles, sprites_bin, output) + + +# -- get_buf ------------------------------------------------------------------ + +def test_pipeline_get_buf_shape(): + assert RenderingPipeline.get_buf().shape == (FRAME_H, FRAME_W) + +def test_pipeline_get_buf_dtype(): + assert RenderingPipeline.get_buf().dtype == np.uint8 + +def test_pipeline_get_buf_zeros(): + assert np.all(RenderingPipeline.get_buf() == 0) + +def test_pipeline_get_buf_independent(): + buf1 = RenderingPipeline.get_buf() + buf2 = RenderingPipeline.get_buf() + buf1[0, 0] = 5 + assert buf2[0, 0] == 0 + + +# -- __repr__ ----------------------------------------------------------------- + +def test_pipeline_repr(test_dir): + rp = make_pipeline(test_dir) + r = repr(rp) + assert "palette" in r + assert "scene" in r + assert "tiles" in r + assert "sprites" in r + assert "output" in r + + +# -- _export ------------------------------------------------------------------ + +def test_pipeline_export_creates_file(test_dir): + rp = make_pipeline(test_dir) + rp._export(RenderingPipeline.get_buf()) + assert Path(rp._output_path).exists() + +def test_pipeline_export_image_size(test_dir): + rp = make_pipeline(test_dir) + rp._export(RenderingPipeline.get_buf()) + img = PILImage.open(rp._output_path) + assert img.size == (FRAME_W, FRAME_H) + +def test_pipeline_export_pixel_color(test_dir): + rp = make_pipeline(test_dir) + buf = RenderingPipeline.get_buf() + buf[0, 0] = 1 # palette index 1 → [17, 17, 17] + rp._export(buf) + assert PILImage.open(rp._output_path).getpixel((0, 0)) == (17, 17, 17) + +def test_pipeline_export_bad_path(test_dir): + rp = make_pipeline(test_dir) + rp._output_path = str(test_dir / "non_esiste" / "output.png") # directory mancante + with pytest.raises(RenderingException): + rp._export(RenderingPipeline.get_buf()) + + +# -- _compose ----------------------------------------------------------------- + +def test_pipeline_compose_tiles_written(test_dir): + rp = make_pipeline(test_dir, tile0_index=1) + buf = RenderingPipeline.get_buf() + rp._compose(buf) + assert buf[0, 0] == 1 + +def test_pipeline_compose_full_tilemap(test_dir): + rp = make_pipeline(test_dir, tile0_index=1) + buf = RenderingPipeline.get_buf() + rp._compose(buf) + assert buf[479, 639] == 1 + +def test_pipeline_compose_sprite_over_tile(test_dir): + sprite = {"id": 0, "x": 0, "y": 0, "flip_x": False, "flip_y": False, "rotation": 0} + rp = make_pipeline(test_dir, tile0_index=1, sprite0_index=2, transparent_index=0, sprites=[sprite]) + buf = RenderingPipeline.get_buf() + rp._compose(buf) + assert buf[0, 0] == 2 + +def test_pipeline_compose_transparent_sprite_not_drawn(test_dir): + sprite = {"id": 0, "x": 0, "y": 0, "flip_x": False, "flip_y": False, "rotation": 0} + rp = make_pipeline(test_dir, tile0_index=1, sprite0_index=0, transparent_index=0, sprites=[sprite]) + buf = RenderingPipeline.get_buf() + rp._compose(buf) + assert buf[0, 0] == 1 + + +# -- render() ----------------------------------------------------------------- + +def test_pipeline_render_creates_output(test_dir): + rp = make_pipeline(test_dir) + rp.render() + assert Path(rp._output_path).exists() + +def test_pipeline_render_output_size(test_dir): + rp = make_pipeline(test_dir) + rp.render() + assert PILImage.open(rp._output_path).size == (FRAME_W, FRAME_H) + +def test_pipeline_compose_sprite_z_order(test_dir): + # sprite 0 (index 2) drawn first, sprite 1 (index 3) drawn second at same position — sprite 1 wins + matrix = np.zeros((256, 256), dtype=np.uint8) + matrix[0:64, 0:64] = 2 # sprite 0 + matrix[0:64, 64:128] = 3 # sprite 1 + (test_dir / "sprites.bin").write_bytes(pack_matrix(matrix)) + scene_data = { + "transparent_index": 0, + "tile_map": [[0] * 20 for _ in range(15)], + "sprites": [ + {"id": 0, "x": 0, "y": 0, "flip_x": False, "flip_y": False, "rotation": 0}, + {"id": 1, "x": 0, "y": 0, "flip_x": False, "flip_y": False, "rotation": 0}, + ], + } + (test_dir / "scene.json").write_text(json.dumps(scene_data)) + rp = RenderingPipeline( + make_pipeline_palette(test_dir), + str(test_dir / "scene.json"), + make_pipeline_tiles(test_dir), + str(test_dir / "sprites.bin"), + str(test_dir / "output.png"), + ) + buf = RenderingPipeline.get_buf() + rp._compose(buf) + assert buf[0, 0] == 3 + +def test_pipeline_compose_sprite_transformation(test_dir): + # sprite 0: left half (cols 0-31) opaque index 2, right half transparent index 0 + # with flip_x the halves swap: buf[0,0] stays tile=1, buf[0,32] becomes 2 + matrix = np.zeros((256, 256), dtype=np.uint8) + matrix[0:64, 0:32] = 2 + (test_dir / "sprites.bin").write_bytes(pack_matrix(matrix)) + scene_data = { + "transparent_index": 0, + "tile_map": [[0] * 20 for _ in range(15)], + "sprites": [{"id": 0, "x": 0, "y": 0, "flip_x": True, "flip_y": False, "rotation": 0}], + } + (test_dir / "scene.json").write_text(json.dumps(scene_data)) + rp = RenderingPipeline( + make_pipeline_palette(test_dir), + str(test_dir / "scene.json"), + make_pipeline_tiles(test_dir, tile0_index=1), + str(test_dir / "sprites.bin"), + str(test_dir / "output.png"), + ) + buf = RenderingPipeline.get_buf() + rp._compose(buf) + assert buf[0, 0] == 1 # flip moved opaque pixels away from col 0 + assert buf[0, 32] == 2 # flip moved opaque pixels to col 32 + +def test_pipeline_compose_sprite_clipping(test_dir): + # sprite at x=-32: only cols 32-63 of the sprite are visible (frame cols 0-31) + sprite = {"id": 0, "x": -32, "y": 0, "flip_x": False, "flip_y": False, "rotation": 0} + rp = make_pipeline(test_dir, tile0_index=1, sprite0_index=2, transparent_index=0, sprites=[sprite]) + buf = RenderingPipeline.get_buf() + rp._compose(buf) + assert buf[0, 0] == 2 # visible part of sprite drawn + assert buf[0, 32] == 1 # tile only — sprite does not reach here + + +# -- main --------------------------------------------------------------------- + +def test_main_help(monkeypatch): + monkeypatch.setattr(sys, "argv", ["main.py", "--help"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 0 + +def test_main_missing_args(monkeypatch): + monkeypatch.setattr(sys, "argv", ["main.py"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code != 0 + +def test_main_runs_ok(monkeypatch, test_dir): + rp = make_pipeline(test_dir) + monkeypatch.setattr(sys, "argv", [ + "main.py", rp._palette_path, rp._scene_path, + rp._tiles_path, rp._sprites_path, rp._output_path, + ]) + main() + assert Path(rp._output_path).exists() + +def test_main_file_not_found(monkeypatch, test_dir): + output = str(test_dir / "output.png") + monkeypatch.setattr(sys, "argv", [ + "main.py", "no.json", "no.json", "no.bin", "no.bin", output, + ]) + with pytest.raises(FileNotFoundError): + main() diff --git a/renderer/uv.lock b/renderer/uv.lock new file mode 100644 index 0000000..d19ab82 --- /dev/null +++ b/renderer/uv.lock @@ -0,0 +1,143 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pap-render" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.4.6" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "pytest", specifier = ">=9.0.3" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +]