feat(stdlib): Tiled P0 — XML reader + base64 decode + gzip framing (#67)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 20s
ci / build-and-test (push) Successful in 1m43s
commit-lint / conventional-commits (push) Successful in 2s
docs / build-and-deploy (push) Successful in 27s

The three parsing primitives the TMX path needs that were not already in
the tree (zlib inflate + the JSON reader already shipped):

- Xml.* — a minimal, deterministic pure-Ludic XML reader for the
  element/attribute/CDATA subset TMX/TSX/TX use, spliced on demand. Handles
  nested elements, single/double-quoted attributes, text + <![CDATA[…]]>,
  comments, the <?xml?> prolog and <!DOCTYPE>, the five predefined entities
  and numeric character references.
- Base64.* — standard base64 (RFC 4648) decode + a matching pure-Ludic
  encoder; the decoder ignores the whitespace Tiled wraps into <data>.
  Splicing Base64.* also pulls in inflate.ludic so a plain tool can run the
  full base64 -> zlib/gzip decode chain.
- z_gunzip — gzip framing (RFC 1952) over the existing DEFLATE inflater:
  skip the 10-byte header + optional fields, inflate the body, ignore the
  CRC32/ISIZE trailer.

Golden corpus vendored under assets/tiled-fixtures/ (mapeditor/tiled
examples, attributed, + hand-authored multi-encoding fixtures). New
example library/tiled_p0.ludic proves all three (21 assertions); docs
pages + inventory added so check-impl stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-01 14:04:49 +03:00
parent 0b149a6876
commit 79776d1f6a
35 changed files with 20549 additions and 18415 deletions

View file

@ -0,0 +1,29 @@
# Tiled golden fixtures
The curated, version-pinned corpus for the Ludic Tiled reader (design record:
[Design: Tiled maps](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design%2FTiled),
issues #67–#74). No single Tiled file covers the format surface, so this is a
subset of the official [`mapeditor/tiled`](https://github.com/mapeditor/tiled)
`examples/` tree plus a few hand-authored files for the gaps the official
examples miss.
## Vendored from mapeditor/tiled (`examples/`)
Fetched from `https://raw.githubusercontent.com/mapeditor/tiled/master/examples/`.
Tiled's example assets carry their own licenses (see the upstream repo's
per-folder `*.license`/README); vendored here with attribution for testing only.
| File | Exercises |
|---|---|
| `desert.tmx` + `desert.tsx` | orthogonal, external `.tsx`, base64+zlib (P0/P1) |
| `sewers.tmx` | orthogonal, base64+zlib, embedded tileset, opacity (P0) |
| `orthogonal-outside.tmx` | object layers, shapes, custom properties (P4) |
| `perspective_walls.tsx` | per-tile bool properties, `<tileoffset>` (P4) |
| `isometric_grass_and_water.tmx` | isometric orientation, Wang set (P5) |
| `hexagonal-mini.tmx` | hexagonal orientation (P5) |
## Hand-authored (CC0 / public domain)
| File | Exercises |
|---|---|
| `handmade.tmx` + `handmade.tsx` | a 4×4 orthogonal map whose four layers carry the **same** GID array in CSV, base64-uncompressed, base64+gzip and base64+zlib — so every encoding path must decode identically. The last tile is GID 1 with the horizontal-flip flag (`0x80000001`), forcing real GID decode. `handmade.tsx` also carries a `solid` bool property and a per-tile `<objectgroup>` collision shape for P2. |

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0" tiledversion="1.1.5" orientation="orthogonal" renderorder="right-down" width="40" height="40" tilewidth="32" tileheight="32" infinite="0" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" source="desert.tsx"/>
<layer id="1" name="Ground" width="40" height="40">
<data encoding="base64" compression="zlib">
eJztmNkKwjAQRaN9cAPrAq5Yq3Xf6v9/nSM2VIbQJjEZR+nDwQZScrwztoORECLySBcIgZ7nc2y4KfyWDLx+Jb9nViNgDEwY+KioAXUgQN4+zpoCMwPmQAtoAx2CLFbA2oDEo9+hwG8DnIDtF/2K8ks086Tw2zH0uyMv7HcRr/6/EvvhnsPrsrxwX7rwU/0ODig/eV3mh3N1ld8eraWPaX6+64s9McesfrqcHfg1MpoifxcVEWjukyw+9AtFPl/I71pER3Of6j4bv7HI54s+MChhqLlPdZ/P3qMmFuo5h5NnTOhjM5tReN2yT51n5/v7J3F0vi46fk+ne7aX0i9l6If7mpufTX3f5wsqv9TAD2fJLT9VrTn7UeZnM5tR+v0LMQOHXwFnxe2/warGFRWf8QDjOLfP
</data>
</layer>
</map>

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.4" tiledversion="1.4.3" name="Desert" tilewidth="32" tileheight="32" spacing="1" margin="1" tilecount="48" columns="8">
<image source="tmw_desert_spacing.png" width="265" height="199"/>
<tile id="30" probability="0.01"/>
<tile id="31" probability="0.01"/>
<tile id="37" probability="0.01"/>
<tile id="38" probability="0.01"/>
<tile id="39" probability="0.01"/>
<tile id="45" probability="0"/>
<tile id="46" probability="0.01"/>
<tile id="47" probability="0.01"/>
<wangsets>
<wangset name="Desert" type="corner" tile="5">
<wangcolor name="Desert" color="#ff0000" tile="29" probability="1"/>
<wangcolor name="Brick" color="#00ff00" tile="9" probability="1"/>
<wangcolor name="Cobblestone" color="#0000ff" tile="33" probability="1"/>
<wangcolor name="Dirt" color="#ff7700" tile="14" probability="1"/>
<wangtile tileid="0" wangid="0,1,0,2,0,1,0,1"/>
<wangtile tileid="1" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="2" wangid="0,1,0,1,0,2,0,1"/>
<wangtile tileid="3" wangid="0,4,0,1,0,4,0,4"/>
<wangtile tileid="4" wangid="0,4,0,4,0,1,0,4"/>
<wangtile tileid="5" wangid="0,1,0,4,0,1,0,1"/>
<wangtile tileid="6" wangid="0,1,0,4,0,4,0,1"/>
<wangtile tileid="7" wangid="0,1,0,1,0,4,0,1"/>
<wangtile tileid="8" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="9" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="10" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="11" wangid="0,1,0,4,0,4,0,4"/>
<wangtile tileid="12" wangid="0,4,0,4,0,4,0,1"/>
<wangtile tileid="13" wangid="0,4,0,4,0,1,0,1"/>
<wangtile tileid="14" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="15" wangid="0,1,0,1,0,4,0,4"/>
<wangtile tileid="16" wangid="0,2,0,1,0,1,0,1"/>
<wangtile tileid="17" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="18" wangid="0,1,0,1,0,1,0,2"/>
<wangtile tileid="19" wangid="0,2,0,1,0,2,0,2"/>
<wangtile tileid="20" wangid="0,2,0,2,0,1,0,2"/>
<wangtile tileid="21" wangid="0,4,0,1,0,1,0,1"/>
<wangtile tileid="22" wangid="0,4,0,1,0,1,0,4"/>
<wangtile tileid="23" wangid="0,1,0,1,0,1,0,4"/>
<wangtile tileid="24" wangid="0,1,0,3,0,1,0,1"/>
<wangtile tileid="25" wangid="0,1,0,3,0,3,0,1"/>
<wangtile tileid="26" wangid="0,1,0,1,0,3,0,1"/>
<wangtile tileid="27" wangid="0,1,0,2,0,2,0,2"/>
<wangtile tileid="28" wangid="0,2,0,2,0,2,0,1"/>
<wangtile tileid="29" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="30" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="31" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="32" wangid="0,3,0,3,0,1,0,1"/>
<wangtile tileid="33" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="34" wangid="0,1,0,1,0,3,0,3"/>
<wangtile tileid="35" wangid="0,3,0,1,0,3,0,3"/>
<wangtile tileid="36" wangid="0,3,0,3,0,1,0,3"/>
<wangtile tileid="37" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="38" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="39" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="40" wangid="0,3,0,1,0,1,0,1"/>
<wangtile tileid="41" wangid="0,3,0,1,0,1,0,3"/>
<wangtile tileid="42" wangid="0,1,0,1,0,1,0,3"/>
<wangtile tileid="43" wangid="0,1,0,3,0,3,0,3"/>
<wangtile tileid="44" wangid="0,3,0,3,0,3,0,1"/>
<wangtile tileid="45" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="46" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="47" wangid="0,1,0,1,0,1,0,1"/>
</wangset>
</wangsets>
</tileset>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored fixture for the Ludic Tiled reader (issue #67). Public domain (CC0). -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="4" height="4" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="1">
<tileset firstgid="1" source="handmade.tsx"/>
<layer id="1" name="csv" width="4" height="4">
<data encoding="csv">
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,2147483649
</data>
</layer>
<layer id="2" name="base64" width="4" height="4">
<data encoding="base64">
AQAAAAIAAAADAAAABAAAAAUAAAAGAAAABwAAAAgAAAAJAAAACgAAAAsAAAAMAAAADQAAAA4AAAAPAAAAAQAAgA==
</data>
</layer>
<layer id="3" name="gzip" width="4" height="4">
<data encoding="base64" compression="gzip">
H4sIAAAAAAAC/w3Dhw0AIAwEsQ+9hpUZPWfJJikxs7CysXNwcnHz8NL5aNIPlvf4ekAAAAA=
</data>
</layer>
<layer id="4" name="zlib" width="4" height="4">
<data encoding="base64" compression="zlib">
eJwNw4cNACAMBLEPvYaVGT1nySYpMbOwsrFzcHJx8/DS+WjSDw1EAPo=
</data>
</layer>
</map>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored fixture for the Ludic Tiled reader (issue #67). Public domain (CC0). -->
<tileset version="1.10" tiledversion="1.10.2" name="handmade" tilewidth="16" tileheight="16" spacing="0" margin="0" tilecount="16" columns="4">
<image source="handmade.png" width="64" height="64"/>
<tile id="4">
<properties>
<property name="solid" type="bool" value="true"/>
</properties>
</tile>
<tile id="6">
<objectgroup draworder="index">
<object id="1" x="0" y="8" width="16" height="8"/>
</objectgroup>
</tile>
</tileset>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0" orientation="hexagonal" renderorder="right-down" width="20" height="20" tilewidth="14" tileheight="12" hexsidelength="6" staggeraxis="y" staggerindex="odd" nextobjectid="2">
<tileset firstgid="1" name="hex mini" tilewidth="18" tileheight="18">
<tileoffset x="0" y="1"/>
<image source="hexmini.png" width="106" height="72"/>
</tileset>
<layer name="Ground" width="20" height="20">
<data encoding="base64" compression="zlib">
eJyl1FEKhDAMBNBSt6jVaL3/Za2QwDAkVdiPQda2zyTonimlU1N6Ws+lkZ6l56AUXcPY2qlniv5uL5Z5BdyDvFXXMoX3Rp44axl6nqFejj3LLK6xgmf3Zg06Qs+O+qiaDOZOVgXPs7jfCme8Hkce1+fNlGdlM3myDTzc580fz1htW2Baj15/R/J72wLvcVZN5HnzGnmVPJ5hNH+0dt33j4ex91TARUs+WjNZz/fewKvJfy+/1naR+dX7OfdEnUYefyOeZZ7Vht/b5HjefxJbO1iTE7YWuEpg5hfPzi8D782x3Mg7DV4=
</data>
</layer>
</map>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.4" tiledversion="1.4.3" orientation="isometric" renderorder="right-down" width="25" height="25" tilewidth="64" tileheight="32" infinite="0" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" name="isometric_grass_and_water" tilewidth="64" tileheight="64" tilecount="24" columns="4">
<tileoffset x="0" y="16"/>
<grid orientation="isometric" width="64" height="32"/>
<image source="isometric_grass_and_water.png" width="256" height="384"/>
<wangsets>
<wangset name="Grass and Water" type="corner" tile="15">
<wangcolor name="Grass" color="#8ab022" tile="0" probability="1"/>
<wangcolor name="Water" color="#378dc2" tile="23" probability="1"/>
<wangtile tileid="0" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="1" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="2" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="3" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="4" wangid="0,1,0,2,0,1,0,1"/>
<wangtile tileid="5" wangid="0,1,0,1,0,2,0,1"/>
<wangtile tileid="6" wangid="0,1,0,1,0,1,0,2"/>
<wangtile tileid="7" wangid="0,2,0,1,0,1,0,1"/>
<wangtile tileid="8" wangid="0,2,0,2,0,2,0,1"/>
<wangtile tileid="9" wangid="0,1,0,2,0,2,0,2"/>
<wangtile tileid="10" wangid="0,2,0,1,0,2,0,2"/>
<wangtile tileid="11" wangid="0,2,0,2,0,1,0,2"/>
<wangtile tileid="12" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="13" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="14" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="15" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="16" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="17" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="18" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="19" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="20" wangid="0,2,0,1,0,2,0,1"/>
<wangtile tileid="21" wangid="0,1,0,2,0,1,0,2"/>
<wangtile tileid="22" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="23" wangid="0,2,0,2,0,2,0,2"/>
</wangset>
</wangsets>
</tileset>
<layer id="1" name="Tile Layer 1" width="25" height="25">
<data encoding="base64" compression="zlib">
eJx1lttywjAMROVgyqVtAoFC/v9L68xoh5PFPGhIYktrrVYyS0QszZ7Nvpvd0n7y24L1Q7MhrTSreN/le821HZ7lv9qYa6sdE0cYs/kX7PXYwtfaevYp7WDrd+SnHByjYr/npP1zZ4/elcuM71rjeckdc5KNHX75fMwc9s2uzb6AsYstJzwrv5/Tz89SLIZy8v203llV8xl7yMU+462/v81OqA114/UhrzUxRqwprnh6ZGzp2PNQfPqRu/X9hnMV8F/xLg1L42erDf2oaa2RI2qPtbgbhmw2H69nMUxx/gVccXdC3AW/o/HV60vW59Lhu8arDxmfGIPFUV1qbLVQEIs4PlOeHQxqVjmzr5mLYsmf+5Qj5yM1r3Ne4p1D5VcMh3qWZibLx2fYkBhPYOv81I9wbrGd45zFU7zrndpwDjkHXXfej9zHc3EG+D3AWcCZMJif7hTnVxr6i9edtoBDz8N7kxqbY6sN9gJnsnqIOqCme7Un76579sIV8dccHvHqZefH76BP9wjzkVapM2rL+5/8cR6QS9eh8p2AT12y5oO9+7yh5hzLZypnHX29/pzB9PE7bOg8Mza5KvGu4R7mp/89zqvr7x+TnxEn
</data>
</layer>
</map>

View file

@ -0,0 +1,285 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.8" tiledversion="1.8.5" orientation="orthogonal" renderorder="right-down" width="45" height="31" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="38">
<properties>
<property name="enemyTint" type="color" value="#ffa33636"/>
</properties>
<tileset firstgid="1" name="outdoor" tilewidth="16" tileheight="16" tilecount="288" columns="24">
<image source="buch-outdoor.png" width="384" height="192"/>
<tile id="6" probability="0.1"/>
<tile id="27" probability="0.05"/>
<tile id="28" probability="0.05"/>
<tile id="30" probability="0.1"/>
<tile id="51" probability="0.05"/>
<tile id="52" probability="0.05"/>
<tile id="54" probability="0.1"/>
<tile id="75" probability="0.05"/>
<tile id="76" probability="0.05"/>
<tile id="78" probability="0.1"/>
<tile id="82" probability="0.1"/>
<tile id="83" probability="0.1"/>
<tile id="99" probability="0.05"/>
<tile id="102" probability="0.1"/>
<tile id="106" probability="0.1"/>
<tile id="107" probability="0.1"/>
<tile id="126" probability="0.1"/>
<wangsets>
<wangset name="Terrains" type="corner" tile="25">
<wangcolor name="Grass" color="#fce94f" tile="150" probability="1"/>
<wangcolor name="Dirt" color="#ef2929" tile="100" probability="1"/>
<wangcolor name="Dark Dirt" color="#f57900" tile="34" probability="1"/>
<wangcolor name="Water" color="#729fcf" tile="171" probability="1"/>
<wangtile tileid="0" wangid="0,1,0,2,0,1,0,1"/>
<wangtile tileid="1" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="2" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="3" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="4" wangid="0,1,0,2,0,2,0,1"/>
<wangtile tileid="5" wangid="0,1,0,1,0,2,0,1"/>
<wangtile tileid="6" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="7" wangid="0,2,0,3,0,2,0,2"/>
<wangtile tileid="8" wangid="0,2,0,3,0,3,0,2"/>
<wangtile tileid="9" wangid="0,2,0,3,0,3,0,2"/>
<wangtile tileid="10" wangid="0,2,0,3,0,3,0,2"/>
<wangtile tileid="11" wangid="0,2,0,3,0,3,0,2"/>
<wangtile tileid="12" wangid="0,2,0,2,0,3,0,2"/>
<wangtile tileid="13" wangid="0,1,0,3,0,1,0,1"/>
<wangtile tileid="14" wangid="0,1,0,3,0,3,0,1"/>
<wangtile tileid="15" wangid="0,1,0,3,0,3,0,1"/>
<wangtile tileid="16" wangid="0,1,0,3,0,3,0,1"/>
<wangtile tileid="17" wangid="0,1,0,3,0,3,0,1"/>
<wangtile tileid="18" wangid="0,1,0,1,0,3,0,1"/>
<wangtile tileid="24" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="25" wangid="0,2,0,1,0,2,0,2"/>
<wangtile tileid="26" wangid="0,2,0,2,0,1,0,2"/>
<wangtile tileid="27" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="28" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="29" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="30" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="31" wangid="0,3,0,3,0,2,0,2"/>
<wangtile tileid="32" wangid="0,3,0,2,0,3,0,3"/>
<wangtile tileid="33" wangid="0,3,0,3,0,2,0,3"/>
<wangtile tileid="34" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="35" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="36" wangid="0,2,0,2,0,3,0,3"/>
<wangtile tileid="37" wangid="0,3,0,3,0,1,0,1"/>
<wangtile tileid="38" wangid="0,3,0,1,0,3,0,3"/>
<wangtile tileid="39" wangid="0,3,0,3,0,1,0,3"/>
<wangtile tileid="40" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="42" wangid="0,1,0,1,0,3,0,3"/>
<wangtile tileid="48" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="49" wangid="0,1,0,2,0,2,0,2"/>
<wangtile tileid="50" wangid="0,2,0,2,0,2,0,1"/>
<wangtile tileid="51" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="52" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="53" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="54" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="55" wangid="0,3,0,3,0,2,0,2"/>
<wangtile tileid="56" wangid="0,2,0,3,0,3,0,3"/>
<wangtile tileid="57" wangid="0,3,0,3,0,3,0,2"/>
<wangtile tileid="58" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="59" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="60" wangid="0,2,0,2,0,3,0,3"/>
<wangtile tileid="61" wangid="0,3,0,3,0,1,0,1"/>
<wangtile tileid="62" wangid="0,1,0,3,0,3,0,3"/>
<wangtile tileid="63" wangid="0,3,0,3,0,3,0,1"/>
<wangtile tileid="66" wangid="0,1,0,1,0,3,0,3"/>
<wangtile tileid="72" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="73" wangid="0,2,0,1,0,2,0,1"/>
<wangtile tileid="74" wangid="0,1,0,2,0,1,0,2"/>
<wangtile tileid="75" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="76" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="77" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="78" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="79" wangid="0,3,0,3,0,2,0,2"/>
<wangtile tileid="80" wangid="0,3,0,2,0,3,0,2"/>
<wangtile tileid="81" wangid="0,2,0,3,0,2,0,3"/>
<wangtile tileid="82" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="83" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="84" wangid="0,2,0,2,0,3,0,3"/>
<wangtile tileid="85" wangid="0,3,0,3,0,1,0,1"/>
<wangtile tileid="86" wangid="0,3,0,1,0,3,0,1"/>
<wangtile tileid="87" wangid="0,1,0,3,0,1,0,3"/>
<wangtile tileid="90" wangid="0,1,0,1,0,3,0,3"/>
<wangtile tileid="96" wangid="0,2,0,2,0,1,0,1"/>
<wangtile tileid="97" wangid="0,1,0,2,0,1,0,2"/>
<wangtile tileid="98" wangid="0,2,0,1,0,2,0,1"/>
<wangtile tileid="99" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="100" wangid="0,2,0,2,0,2,0,2"/>
<wangtile tileid="101" wangid="0,1,0,1,0,2,0,2"/>
<wangtile tileid="102" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="103" wangid="0,3,0,3,0,2,0,2"/>
<wangtile tileid="104" wangid="0,2,0,3,0,2,0,3"/>
<wangtile tileid="105" wangid="0,3,0,2,0,3,0,2"/>
<wangtile tileid="106" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="107" wangid="0,3,0,3,0,3,0,3"/>
<wangtile tileid="108" wangid="0,2,0,2,0,3,0,3"/>
<wangtile tileid="109" wangid="0,3,0,3,0,1,0,1"/>
<wangtile tileid="110" wangid="0,1,0,3,0,1,0,3"/>
<wangtile tileid="111" wangid="0,3,0,1,0,3,0,1"/>
<wangtile tileid="114" wangid="0,1,0,1,0,3,0,3"/>
<wangtile tileid="120" wangid="0,2,0,1,0,1,0,1"/>
<wangtile tileid="121" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="122" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="123" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="124" wangid="0,2,0,1,0,1,0,2"/>
<wangtile tileid="125" wangid="0,1,0,1,0,1,0,2"/>
<wangtile tileid="126" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="127" wangid="0,3,0,2,0,2,0,2"/>
<wangtile tileid="128" wangid="0,3,0,2,0,2,0,3"/>
<wangtile tileid="129" wangid="0,3,0,2,0,2,0,3"/>
<wangtile tileid="130" wangid="0,3,0,2,0,2,0,3"/>
<wangtile tileid="131" wangid="0,3,0,2,0,2,0,3"/>
<wangtile tileid="132" wangid="0,2,0,2,0,2,0,3"/>
<wangtile tileid="133" wangid="0,3,0,1,0,1,0,1"/>
<wangtile tileid="134" wangid="0,3,0,1,0,1,0,3"/>
<wangtile tileid="135" wangid="0,3,0,1,0,1,0,3"/>
<wangtile tileid="136" wangid="0,3,0,1,0,1,0,3"/>
<wangtile tileid="137" wangid="0,3,0,1,0,1,0,3"/>
<wangtile tileid="138" wangid="0,1,0,1,0,1,0,3"/>
<wangtile tileid="144" wangid="0,1,0,4,0,1,0,1"/>
<wangtile tileid="145" wangid="0,1,0,4,0,4,0,1"/>
<wangtile tileid="146" wangid="0,1,0,4,0,4,0,1"/>
<wangtile tileid="147" wangid="0,1,0,4,0,4,0,1"/>
<wangtile tileid="148" wangid="0,1,0,4,0,4,0,1"/>
<wangtile tileid="149" wangid="0,1,0,1,0,4,0,1"/>
<wangtile tileid="150" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="151" wangid="0,2,0,4,0,2,0,2"/>
<wangtile tileid="152" wangid="0,2,0,4,0,4,0,2"/>
<wangtile tileid="153" wangid="0,2,0,4,0,4,0,2"/>
<wangtile tileid="154" wangid="0,2,0,4,0,4,0,2"/>
<wangtile tileid="155" wangid="0,2,0,4,0,4,0,2"/>
<wangtile tileid="156" wangid="0,2,0,2,0,4,0,2"/>
<wangtile tileid="168" wangid="0,4,0,4,0,1,0,1"/>
<wangtile tileid="169" wangid="0,4,0,1,0,4,0,4"/>
<wangtile tileid="170" wangid="0,4,0,4,0,1,0,4"/>
<wangtile tileid="171" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="172" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="173" wangid="0,1,0,1,0,4,0,4"/>
<wangtile tileid="174" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="175" wangid="0,4,0,4,0,2,0,2"/>
<wangtile tileid="176" wangid="0,4,0,2,0,4,0,4"/>
<wangtile tileid="177" wangid="0,4,0,4,0,2,0,4"/>
<wangtile tileid="178" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="179" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="180" wangid="0,2,0,2,0,4,0,4"/>
<wangtile tileid="192" wangid="0,4,0,4,0,1,0,1"/>
<wangtile tileid="193" wangid="0,1,0,4,0,4,0,4"/>
<wangtile tileid="194" wangid="0,4,0,4,0,4,0,1"/>
<wangtile tileid="195" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="196" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="197" wangid="0,1,0,1,0,4,0,4"/>
<wangtile tileid="198" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="199" wangid="0,4,0,4,0,2,0,2"/>
<wangtile tileid="200" wangid="0,2,0,4,0,4,0,4"/>
<wangtile tileid="201" wangid="0,4,0,4,0,4,0,2"/>
<wangtile tileid="202" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="203" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="204" wangid="0,2,0,2,0,4,0,4"/>
<wangtile tileid="216" wangid="0,4,0,4,0,1,0,1"/>
<wangtile tileid="217" wangid="0,4,0,1,0,4,0,1"/>
<wangtile tileid="218" wangid="0,1,0,4,0,1,0,4"/>
<wangtile tileid="219" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="220" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="221" wangid="0,1,0,1,0,4,0,4"/>
<wangtile tileid="222" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="223" wangid="0,4,0,4,0,2,0,2"/>
<wangtile tileid="224" wangid="0,4,0,2,0,4,0,2"/>
<wangtile tileid="225" wangid="0,2,0,4,0,2,0,4"/>
<wangtile tileid="226" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="227" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="228" wangid="0,2,0,2,0,4,0,4"/>
<wangtile tileid="240" wangid="0,4,0,4,0,1,0,1"/>
<wangtile tileid="241" wangid="0,1,0,4,0,1,0,4"/>
<wangtile tileid="242" wangid="0,4,0,1,0,4,0,1"/>
<wangtile tileid="243" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="244" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="245" wangid="0,1,0,1,0,4,0,4"/>
<wangtile tileid="246" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="247" wangid="0,4,0,4,0,2,0,2"/>
<wangtile tileid="248" wangid="0,2,0,4,0,2,0,4"/>
<wangtile tileid="249" wangid="0,4,0,2,0,4,0,2"/>
<wangtile tileid="250" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="251" wangid="0,4,0,4,0,4,0,4"/>
<wangtile tileid="252" wangid="0,2,0,2,0,4,0,4"/>
<wangtile tileid="264" wangid="0,4,0,1,0,1,0,1"/>
<wangtile tileid="265" wangid="0,4,0,1,0,1,0,4"/>
<wangtile tileid="266" wangid="0,4,0,1,0,1,0,4"/>
<wangtile tileid="267" wangid="0,4,0,1,0,1,0,4"/>
<wangtile tileid="268" wangid="0,4,0,1,0,1,0,4"/>
<wangtile tileid="269" wangid="0,1,0,1,0,1,0,4"/>
<wangtile tileid="270" wangid="0,1,0,1,0,1,0,1"/>
<wangtile tileid="271" wangid="0,4,0,2,0,2,0,2"/>
<wangtile tileid="272" wangid="0,4,0,2,0,2,0,4"/>
<wangtile tileid="273" wangid="0,4,0,2,0,2,0,4"/>
<wangtile tileid="274" wangid="0,4,0,2,0,2,0,4"/>
<wangtile tileid="275" wangid="0,4,0,2,0,2,0,4"/>
<wangtile tileid="276" wangid="0,2,0,2,0,2,0,4"/>
</wangset>
</wangsets>
</tileset>
<layer id="1" name="Ground" width="45" height="31">
<data encoding="base64" compression="zlib">
eJyNWE1vVVUU3Y0KQeXL4kBL7QAiEkcopQOIkjgysXagYeLIpE0HGucEtY5U6giw1Bj8AdiWxhZ+ANLS4nv+AJKW1+TVH9DklWfSR+LeeWt51j3eWxmsnPvuPR9rr7P3Pvu8hpkd7DFb8dYb24Pf89bFtOOio8/xquNreZ/jdccxx0nH53j+SN7F81nHOfz+BGs1vP0C706i/1kZ+6asMWRdrvMY+4q3bzmedTwj/FfAO/i2HVcc10s4f+t4QzidcEw6vnd8lz0HfrAu3wOO8xj3seO44AT6cY0GONPWTx1jjp8cvzjO4Fsbfan/luP3XXgrJ+4XtWnjeQX9VzBmErgsdud8lXdwCb94G5w3HE+g+R5ZcwDPDx114U3dY569wkW5UZ8J8I6+AfalPbT7MvhS03nMMV3CNzT+A5zHMt70obvezjrWwfue47HwynUhH3JivwG01E1Be0cs8eW3mOd9x2lwXHVsOx7AP8bgI/PCZ5+PmwHvHceSaK76UZO2cGyg1X2mT/eKrtOwUX2Le/RcicbB4zfHyz6239sPs3VijtB6E33XRPPr0CbXmpwaAvrEPPyBvsDvOn4IbQ+4jkHnDaxfE52POl6zFIe0n1oH12Vw37Hq2HzH8W5o6DgE9FrRn+l7uk8rVvSZi6LvDdF4GaBPU2edS32kDjub1vWrMt6Mq8gJkc/OwQbypq9T54jrKcfVkrl+Fq5ctwn9/hTOZefIgPC+7VhEW9tFb+bwyL2Rw3le5DHGeVuY6xrma0DPJtpYL+Ip/JT+TM7uT9/k649jji3MEWMO+5gF2Py4hLOel8y/A1bMKdRbeW8K99BkzlIOmMVe37GuL2u+y/PYCNbbAkfGwCo0vyX9cq0nwbcsXnP/e97xAjADzutoQ6def3+kJ2lMnCmZL/wl/C7y87bsTXDuOB5Bt//jpbgiflBlQwe6am4bE40Dw6J1PscW9inmiJhlvvzVcaAn7fnT8JyC/ZG77u/SX3Nx8CPnQNRxl6xY05XZPYN5HoBv+F+VL49kccF9on9GG757q2J84KXMD0bBrw/+EHt7yro5umqOh7IW1330FHtNvkvQdg72co8GKtajnn2ib+QJ1p7T2XMgzlDPI//mv+C8Db5NWVO/MeangB/Bt4Y9CnuXs7FVIEfWofy9d5cxPF8mRKtNaDQLvxiy5O/xvgMteQ5si7Y1PLdkf8owjvW+hIan7L85gnXJRLd/wQ+ZD+jPjOU1K9ao1JLfeWatgfdtvGcNsBtnzjcu+/+VdWMubNA6i3lNx9MWahl7qvUHa1RqS79pWjord9C/hedFjLsKaByQ72KJLax1437GmqZX+OV2Mz+FP9y0dI52wKuFd3y/CH3Zh1qz1f7x7n62X/USHm3ggqU7pdYyeZ57EedTxI3W1XXw25T1a+DFs4vn/Bz4d7BHm5bOOe4XvzdLeBBRO0YdwzszeYevjFvymYg1xiv1qFvKtx1o17QUi1EXMHYOgfsR8G9Z8hXWiZyrjvFVnFl/kfOxjDdrmeAZeeuapTOce8x6gHquAVEv6jnL51VozH6shdbAn3VnrBvxdtCS7/LdeUv/NUQNxntlG7ypK/dxHWuw/tbzlTVIrMlaKu4Zeu4egm30I+a/WbTL+KaaHoeWh8GX+sZ/I1HnDlixFr9nqY5dtFQztjK+vMurbsH7hqU7J/X+GxyXMGcNWJI9oy9csOJ/PKEr/0cJe96Dvjdl/3g/2ZZ1uH9/Wbe2GnR8AC7Rb8dSPOV2jaIfc7ae6ew7akX/nQRP+kC8j/OO98vgvA95Yj/q2v2SK7Sm5fx5rC2gT/R/YsXacRT7EeMXLMVdcNa4rYrByMd6B676j44+3QFn1U7jTLkzz6nGw/I93m9Yyot5HcdzhPkrELkrYjD8mnGZc2a9pnUO+Q5mPEO/fivG21Grtktty+8eqjM58b873tk+s25c6t0+cNdS/cAYv1Oxfhn6pR20oh+dzvoOZ/b+A6OnAUo=
</data>
</layer>
<layer id="2" name="Fringe" width="45" height="31">
<data encoding="base64" compression="zlib">
eJzVl9lNw0AURZ8ltgr4YquAjliaYPl8DdAChCVABRB2KkBhpwK2sKQC4FgiShQ8tseMx/aVjmRLY+fO3JnnFxGRGZiVamkBFos2EaOBQGQw6N6PFmcltcbwOx50vU+neKYztsb1OmzAZq4uo9XxbjO2wfUBHMJRjt5c6RyacAlXcF2oGzu9wCu0YCohpwcPftJqFdZ+r9uGMf1nvWjtwX7P/UiEN5vzUiZRW5Qao9QarUHRftKI2qLUGKXWaCOjZ9/zprYoNUapNdrM+Hsu5m0jaotSY5Rao62M73Ax70d4gme4yOgjSb01tH/eu/947xd8izvfS7Ds6F2+VZP8e5oVx+8rY0/D90dN385QPnuapN7DZnxvT1O2HiCNqtYD2GaXt3aot9tQh62K9CJn+DyFEziuiOd7fN7BLdyUxDN5C7kL+Yf74I8+8fkB7/AGQ+zdYU/7t21YI/IWchfyD/dBoibwO1nwmSNvIXch/3AfxCopE18ibyF3If9wH8TKNhOfMq2nTSZ5yXSeTOvZn0nU83Mw78Bb1P/tUKbzlHY9k87jD20Li3Y=
</data>
</layer>
<objectgroup id="3" name="Objects">
<object id="1" name="maggots" type="Location" x="435" y="74" width="155" height="99">
<properties>
<property name="spawncount" type="int" value="5"/>
<property name="spawntype" value="maggot"/>
</properties>
</object>
<object id="2" name="discover chest" type="Trigger" x="201" y="200" width="127" height="127">
<properties>
<property name="script" type="file" value="chest-discovered.lua"/>
</properties>
<ellipse/>
</object>
<object id="3" name="unreachable" type="Fixture" x="2" y="158">
<properties>
<property name="static" type="bool" value="true"/>
</properties>
<polygon points="0,0 55,-23 96,-117 110,-61 104,-42 119,-33 116,6 104,9 100,36 60,43 53,58 43,58 34,74 21,69 18,90 0,89"/>
</object>
<object id="5" name="guard" type="NPC" x="22" y="361">
<polyline points="-3,120 87,91 154,96 181,16 273,-1"/>
</object>
<object id="6" name="guard" type="NPC" x="277" y="18">
<polyline points="0,0 75,78 133,82 176,179 274,183"/>
</object>
<object id="10" gid="282" x="413.333" y="225.333" width="16" height="16"/>
<object id="11" gid="282" x="421.667" y="218" width="16" height="16"/>
<object id="12" gid="2147483930" x="423" y="235.333" width="16" height="16"/>
<object id="13" gid="282" x="5" y="70" width="16" height="16"/>
<object id="14" gid="282" x="-3.66667" y="80.3333" width="16" height="16"/>
<object id="16" gid="283" x="538" y="418.333" width="16" height="16"/>
<object id="17" gid="283" x="407.667" y="462" width="16" height="16"/>
<object id="18" gid="283" x="417" y="473.667" width="16" height="16"/>
<object id="19" gid="283" x="402.667" y="469" width="16" height="16"/>
<object id="21" gid="2147483930" x="683.333" y="260.5" width="16" height="16"/>
<object id="22" gid="282" x="692.167" y="269.167" width="16" height="16"/>
<object id="23" gid="282" x="701.667" y="247.833" width="16" height="16"/>
<object id="24" gid="282" x="688.5" y="242" width="16" height="16"/>
<object id="25" gid="282" x="670.5" y="263.5" width="16" height="16"/>
<object id="26" gid="282" x="680" y="284" width="16" height="16"/>
<object id="27" gid="282" x="643.833" y="283.667" width="16" height="16"/>
<object id="28" gid="282" x="63.4165" y="386" width="16" height="16"/>
<object id="29" gid="282" x="9.0835" y="356.167" width="16" height="16"/>
<object id="30" gid="282" x="11.9165" y="385" width="16" height="16"/>
<object id="31" gid="282" x="54.2495" y="378.5" width="16" height="16"/>
<object id="32" gid="2147483930" x="2.4165" y="364.5" width="16" height="16"/>
<object id="33" gid="2147483930" x="41.5835" y="382.833" width="16" height="16"/>
<object id="34" type="Sign" gid="257" x="670.667" y="87" width="16" height="16">
<properties>
<property name="text" value="East West"/>
</properties>
</object>
<object id="37" name="player-start" type="Location" x="192" y="160">
<point/>
</object>
</objectgroup>
</map>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<tileset name="perspective_walls" tilewidth="64" tileheight="64">
<tileoffset x="-32" y="0"/>
<image source="perspective_walls.png"/>
<tile id="13">
<properties>
<property name="door" value="true"/>
</properties>
</tile>
<tile id="14">
<properties>
<property name="door" value="true"/>
</properties>
</tile>
<tile id="15">
<properties>
<property name="pickup" value="true"/>
</properties>
</tile>
</tileset>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0" orientation="orthogonal" width="50" height="50" tilewidth="24" tileheight="24">
<tileset firstgid="1" name="sewer_tileset" tilewidth="24" tileheight="24">
<image source="sewer_tileset.png" trans="ff00ff" width="192" height="217"/>
</tileset>
<layer name="Bottom" width="50" height="50">
<data encoding="base64" compression="zlib">
eJzt19kKwjAQBdDim0sFqwguL3Vf/sP//ySnkIFhSGrSdEnxPhyQxqJ32kySPMuyJTkZC5KLa2dyNEo1Lsds3wkl/4f+7V/0/f+Y40Je5GpUn2+J5NiQCdmO/HlMyYzMI3JwLUJx7drIsSIFWUfk4FrY3GvGuHb8vr4jcmhNcnAtpIflmmar3VA5nqaWMUKeQ1d9dyht59iRPTmMPEdpua8Put/Frh88P5q84zFkv/NZP2TOlOaH7Hc+64fMmdL8cPVbV9+tcn5MzpTmR0gv12uDbQ4MNT8AIA73Uq3v3hqLe2ldTx4D21k1tf2ubw59Vk1tX+KbQ59VXfuSlMn9SJHV70tS5jqrYu8BAAAAAAAAAABd+wIHfQq1
</data>
</layer>
<layer name="Top" width="50" height="50" opacity="0.49">
<data encoding="base64" compression="zlib">
eJzt1jsKgDAQQEELtVKvYuGvEDvvfya3MBeQQAzMwCPdsum2af5lL71AJv7xL7X+Y46WtzXayq7z2RGd0f2+V6a5bdRFfaZ5pQzRGE2lFwEAoArpDk7Veg+nOzjlHgYAAAAAIIcHvboDlQ==
</data>
</layer>
</map>

6
changes/tiled-maps.md Normal file
View file

@ -0,0 +1,6 @@
bump: minor
type: feat
**Tiled map support (#67–#74)** — load and draw [Tiled](https://www.mapeditor.org/) maps (TMX/TSX/TX and TMJ/TSJ/TJ), the design record from #66.
- **P0 parsing primitives (#67)** — a minimal pure-Ludic XML reader (`Xml.*`) for the element/attribute/CDATA subset TMX/TSX/TX use; standard base64 decode/encode (`Base64.*`, RFC 4648), whose decoder ignores the whitespace Tiled wraps into `<data>`; and gzip framing (`z_gunzip`, RFC 1952) wrapping the existing DEFLATE inflater. zlib and the JSON reader already shipped. A curated, attributed golden corpus lands under `assets/tiled-fixtures/`.

View file

@ -0,0 +1,7 @@
---
id: base64
title: Base64
order: 41
---
Standard base64 (RFC 4648) — <a href="base64-decode"><code>Base64.decode</code></a> reads base64 text into the bytes it stands for, and <a href="base64-encode"><code>Base64.encode</code></a> is the inverse. The decoder ignores ASCII whitespace, so it reads the newline-wrapped base64 that Tiled writes inside a <code>&lt;data&gt;</code> element; that output then feeds the DEFLATE inflater for zlib/gzip-compressed layer data. Deterministic; spliced on demand when a program mentions <code>Base64.*</code>. (The security-sensitive encoder <a href="crypto-base64"><code>Crypto.base64</code></a> is a separate, hardened path.)

View file

@ -0,0 +1,14 @@
---
id: base64-decode
name: Base64.decode
category: base64
kind: namespace-method
tokens: Base64.decode
sig: Base64.decode(src: string) -> string
tip: Decode standard base64 text to bytes.
order: 1
ns: Base64
member: decode
---
Decodes base64 <code>src</code> to the bytes it represents, returned as a NUL-terminated string. ASCII whitespace is ignored and <code>=</code> padding ends the stream. For binary payloads that may contain a NUL byte, decode into a sized buffer at the runtime level and track the returned length instead.

View file

@ -0,0 +1,14 @@
---
id: base64-encode
name: Base64.encode
category: base64
kind: namespace-method
tokens: Base64.encode
sig: Base64.encode(src: string) -> string
tip: Encode bytes as standard base64 text.
order: 2
ns: Base64
member: encode
---
Encodes the bytes of <code>src</code> as standard base64 with <code>=</code> padding — the inverse of <a href="base64-decode"><code>Base64.decode</code></a>.

View file

@ -0,0 +1,7 @@
---
id: xml
title: Xml
order: 40
---
A minimal, deterministic XML reader for the element/attribute/CDATA subset the native Tiled formats (TMX/TSX/TX) use. <a href="xml-parse"><code>Xml.parse</code></a> turns a document into an element tree; the accessors read a node's <a href="xml-tag"><code>tag</code></a>, <a href="xml-text"><code>text</code></a>, attributes (<a href="xml-attr"><code>Xml.attr</code></a>/<a href="xml-attr_int"><code>Xml.attr_int</code></a>/<a href="xml-has"><code>Xml.has</code></a>) and children (<a href="xml-child"><code>Xml.child</code></a>/<a href="xml-find"><code>Xml.find</code></a>/<a href="xml-count"><code>Xml.count</code></a>). It expands the five predefined entities and numeric character references, skips the <code>&lt;?xml?&gt;</code> prolog, comments and <code>&lt;!DOCTYPE&gt;</code>, and is best-effort rather than validating — the same contract as <a href="json"><code>Json</code></a>. Spliced on demand when a program mentions <code>Xml.*</code>.

View file

@ -0,0 +1,14 @@
---
id: xml-attr
name: Xml.attr
category: xml
kind: namespace-method
tokens: Xml.attr
sig: Xml.attr(node, key: string) -> string
tip: An attribute's value by name.
order: 4
ns: Xml
member: attr
---
Returns the value of attribute <code>key</code> (entity-decoded), or <code>""</code> if the element has no such attribute. Use <a href="xml-has"><code>Xml.has</code></a> to distinguish an empty value from an absent one.

View file

@ -0,0 +1,14 @@
---
id: xml-attr_count
name: Xml.attr_count
category: xml
kind: namespace-method
tokens: Xml.attr_count
sig: Xml.attr_count(node) -> int
tip: How many attributes the element has.
order: 7
ns: Xml
member: attr_count
---
Returns the number of attributes on <code>node</code>.

View file

@ -0,0 +1,14 @@
---
id: xml-attr_int
name: Xml.attr_int
category: xml
kind: namespace-method
tokens: Xml.attr_int
sig: Xml.attr_int(node, key: string, dflt: int) -> int
tip: An attribute parsed as an integer.
order: 5
ns: Xml
member: attr_int
---
Returns attribute <code>key</code> parsed as a signed decimal integer, or <code>dflt</code> when the attribute is absent.

View file

@ -0,0 +1,14 @@
---
id: xml-child
name: Xml.child
category: xml
kind: namespace-method
tokens: Xml.child
sig: Xml.child(node, index: int) -> xml
tip: A child element by index.
order: 9
ns: Xml
member: child
---
Returns the child element at <code>index</code> in document order, or an empty node if the index is out of range.

View file

@ -0,0 +1,14 @@
---
id: xml-child_count
name: Xml.child_count
category: xml
kind: namespace-method
tokens: Xml.child_count
sig: Xml.child_count(node) -> int
tip: How many child elements.
order: 8
ns: Xml
member: child_count
---
Returns the number of direct child elements of <code>node</code> (text and comments are not children).

View file

@ -0,0 +1,14 @@
---
id: xml-count
name: Xml.count
category: xml
kind: namespace-method
tokens: Xml.count
sig: Xml.count(node, tag: string) -> int
tip: How many children have a given tag.
order: 11
ns: Xml
member: count
---
Returns how many direct children of <code>node</code> are named <code>tag</code> — e.g. the number of <code>&lt;layer&gt;</code> elements in a map.

View file

@ -0,0 +1,14 @@
---
id: xml-find
name: Xml.find
category: xml
kind: namespace-method
tokens: Xml.find
sig: Xml.find(node, tag: string) -> xml
tip: The first child with a given tag.
order: 10
ns: Xml
member: find
---
Returns the first direct child of <code>node</code> whose tag is <code>tag</code>, or an empty node if there is none.

View file

@ -0,0 +1,14 @@
---
id: xml-has
name: Xml.has
category: xml
kind: namespace-method
tokens: Xml.has
sig: Xml.has(node, key: string) -> int
tip: Whether an attribute is present.
order: 6
ns: Xml
member: has
---
Returns <code>1</code> if <code>node</code> carries attribute <code>key</code>, else <code>0</code>.

View file

@ -0,0 +1,14 @@
---
id: xml-parse
name: Xml.parse
category: xml
kind: namespace-method
tokens: Xml.parse
sig: Xml.parse(text: string) -> xml
tip: Parse XML text into an element tree.
order: 1
ns: Xml
member: parse
---
Parses an XML document and returns its root element (or an empty node if there is none). Handles nested elements, single- and double-quoted attributes, text/<code>&lt;![CDATA[…]]&gt;</code> content, comments, the <code>&lt;?xml?&gt;</code> prolog and <code>&lt;!DOCTYPE&gt;</code>. Best-effort, not validating.

View file

@ -0,0 +1,14 @@
---
id: xml-tag
name: Xml.tag
category: xml
kind: namespace-method
tokens: Xml.tag
sig: Xml.tag(node) -> string
tip: The element's tag name.
order: 2
ns: Xml
member: tag
---
Returns the element name of <code>node</code> (e.g. <code>tileset</code>), or <code>""</code> for the empty node.

View file

@ -0,0 +1,14 @@
---
id: xml-text
name: Xml.text
category: xml
kind: namespace-method
tokens: Xml.text
sig: Xml.text(node) -> string
tip: The element's character data.
order: 3
ns: Xml
member: text
---
Returns the concatenated character data of <code>node</code> — the CSV inside a <code>&lt;data encoding="csv"&gt;</code> element, or a <code>&lt;property&gt;</code> value, lands here (entity-decoded).

View file

@ -0,0 +1,61 @@
# tiled_p0.ludic — the Tiled P0 parsing primitives (issue #67): the pure-Ludic
# XML reader (Xml.*), base64 decode/encode (Base64.*), and gzip framing over the
# existing DEFLATE inflater (z_gunzip). These are the three primitives the TMX
# path needs that were not already in the tree (zlib + JSON already shipped).
#
# Each assertion that holds prints its number, so a full run prints:
# 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
program TiledP0 {
entry {
# --- base64 decode: RFC 4648 test vectors (every padding remainder) ---
if Base64.decode("") == "" { print(1) }
if Base64.decode("Zg==") == "f" { print(2) }
if Base64.decode("Zm8=") == "fo" { print(3) }
if Base64.decode("Zm9v") == "foo" { print(4) }
if Base64.decode("Zm9vYg==") == "foob" { print(5) }
if Base64.decode("Zm9vYmE=") == "fooba" { print(6) }
if Base64.decode("Zm9vYmFy") == "foobar" { print(7) }
# decode ignores embedded whitespace/newlines (as TMX <data> carries)
if Base64.decode("Zm9v\n YmFy") == "foobar" { print(8) }
# --- base64 encode round-trips ---
if Base64.encode("foobar") == "Zm9vYmFy" { print(9) }
if Base64.encode("f") == "Zg==" { print(10) }
if Base64.decode(Base64.encode("the quick brown fox")) == "the quick brown fox" { print(11) }
# --- XML reader over a TSX element tree ---
let tsx = "<?xml version='1.0' encoding='UTF-8'?>\n<!-- a comment -->\n<tileset version='1.10' name='sheet &amp; co' tilewidth='16' tileheight='16' tilecount='4' columns='2'>\n <image source='sheet.png' width='32' height='32'/>\n <tile id='0'><properties><property name='solid' type='bool' value='true'/></properties></tile>\n <data encoding='csv'>1,2,3,4</data>\n</tileset>\n"
let root = Xml.parse(tsx)
if Xml.tag(root) == "tileset" { print(12) }
if Xml.attr(root, "name") == "sheet & co" { print(13) } # entity decode
if Xml.attr_int(root, "tilewidth", 0) == 16 { print(14) }
if Xml.attr_int(root, "tilecount", 0) == 4 { print(15) }
let img = Xml.find(root, "image")
if Xml.attr(img, "source") == "sheet.png" { print(16) }
let tile = Xml.find(root, "tile")
let props = Xml.find(tile, "properties")
let prop = Xml.find(props, "property")
if Xml.attr(prop, "value") == "true" { print(17) }
let data = Xml.find(root, "data")
if Xml.text(data) == "1,2,3,4" { print(18) }
# --- gzip framing: base64 -> bytes -> gunzip round-trips ---
let gz = "H4sIAAAAAAAC//NIzcnJ11EIycxJTVFIr8osUCjPL8pJUVQwMDQyNjE1M7ewVCjJSFUoLM1MzlZIKsovz1NIy6/QAwCLdXOzOAAAAA=="
let comp = bytes(256)
let clen = b64_decode(gz, comp)
let out = bytes(256)
let n = z_gunzip(comp, clen, out, 256)
out[n] = 0
if n == 56 { print(19) }
if out == "Hello, Tiled gzip world! 0123456789 the quick brown fox." { print(20) }
# --- zlib still round-trips through the same path (regression) ---
let zl = "eJzzSM3JyddRCMnMSU1RSK/KLFAozy/KSVFUMDA0MjYxNTO3sFQoyUhVKCzNTM5WSCrKL89TSMuv0AMAA10SJA=="
let zc = bytes(256)
let zclen = b64_decode(zl, zc)
let zout = bytes(256)
let zn = z_uncompress(zc, zclen, zout, 256)
zout[zn] = 0
if zout == "Hello, Tiled gzip world! 0123456789 the quick brown fox." { print(21) }
}
}

View file

@ -0,0 +1,87 @@
# ============================================================================
# base64.ludic — standard base64 (RFC 4648) encode + decode, in Ludic (`Base64.*`).
#
# `Crypto.base64` already encodes (hand-written IR, tied to the crypto prelude);
# the Tiled data pipeline (issue #67) needs a *decoder* — base64 layer data feeds
# the DEFLATE inflater. This is that decoder, plus a matching pure-Ludic encoder
# so the namespace is self-contained and spliceable on its own (no crypto prelude
# dependency). Deterministic, allocation the only cost.
#
# ludicc splices this file when a program mentions `Base64.*` (parse.ludic). The
# decoder ignores ASCII whitespace (TMX embeds newlines/indentation in its base64
# `<data>`), and '=' padding ends the stream.
# ============================================================================
# the standard alphabet value of a base64 character, or -1 for anything else
# (whitespace, '=', stray bytes — the decoder skips them / stops on '=').
function b64_val(c: int) -> int {
if c >= 65 and c <= 90 { return c - 65 } # 'A'..'Z' -> 0..25
if c >= 97 and c <= 122 { return c - 71 } # 'a'..'z' -> 26..51
if c >= 48 and c <= 57 { return c + 4 } # '0'..'9' -> 52..61
if c == 43 { return 62 } # '+'
if c == 47 { return 63 } # '/'
return 0 - 1
}
# decode NUL-terminated base64 `src` into the caller's `out` buffer; returns the
# number of bytes written. Whitespace is ignored; '=' padding terminates. `out`
# must hold at least (len(src)*3)/4 bytes.
function b64_decode(src: pointer, out: pointer) -> int {
var acc = 0
var nbits = 0
var w = 0
var i = 0
var c = src[i]
while c != 0 {
if c == 61 { return w } # '=' -> done
let v = b64_val(c)
if v >= 0 {
acc = (acc << 6) | v
nbits = nbits + 6
if nbits >= 8 {
nbits = nbits - 8
out[w] = (acc >> nbits) & 255
w = w + 1
}
}
i = i + 1
c = src[i]
}
return w
}
# decode base64 text -> a fresh NUL-terminated string of the decoded bytes.
# (For binary payloads that may contain NUL, decode into a sized buffer with
# `b64_decode` and track the returned length instead.)
function base64_decode(src: pointer) -> pointer {
let cap = len(src) + 4
let out = bytes(cap)
let w = b64_decode(src, out)
out[w] = 0
return out
}
# encode the bytes of NUL-terminated `src` as standard base64 (with '=' padding).
function base64_encode(src: pointer) -> pointer {
let tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
let n = len(src)
var out = ""
var i = 0
while i < n {
let b0 = src[i]
var b1 = 0
var b2 = 0
var have = 1
if i + 1 < n { b1 = src[i + 1]; have = 2 }
if i + 2 < n { b2 = src[i + 2]; have = 3 }
let e0 = b0 >> 2
let e1 = ((b0 & 3) << 4) | (b1 >> 4)
let e2 = ((b1 & 15) << 2) | (b2 >> 6)
let e3 = b2 & 63
out = out + tab[e0..e0 + 1] + tab[e1..e1 + 1]
if have >= 2 { out = out + tab[e2..e2 + 1] } else { out = out + "=" }
if have >= 3 { out = out + tab[e3..e3 + 1] } else { out = out + "=" }
i = i + 3
}
return out
}

View file

@ -274,3 +274,34 @@ function z_uncompress(src: pointer, len: int, out: pointer, cap: int) -> int {
if (cmf & 15) != 8 { return -1 }
return z_inflate(offset(src, 2), len - 2, out, cap)
}
# gzip framing (RFC 1952): a 10-byte header (magic 1f 8b, CM=8, FLG, 4-byte MTIME,
# XFL, OS), optional FEXTRA/FNAME/FCOMMENT/FHCRC fields selected by FLG, then the
# same DEFLATE body zlib carries, then an 8-byte CRC32 + ISIZE trailer. We skip
# the header + optional fields, inflate the body, and ignore the trailer — the
# CRC is a redundancy check, not needed to decode (PNG likewise ignores ancillary
# CRCs). Returns bytes written, or -1.
function z_gunzip(src: pointer, len: int, out: pointer, cap: int) -> int {
if len < 18 { return -1 } # 10 header + 8 trailer minimum
if src[0] != 31 { return -1 } # 0x1f
if src[1] != 139 { return -1 } # 0x8b
if src[2] != 8 { return -1 } # CM must be DEFLATE
let flg = src[3]
var pos = 10 # past the fixed header
if (flg & 4) != 0 { # FEXTRA: 2-byte length then that many bytes
if pos + 2 > len { return -1 }
let xlen = src[pos] + (src[pos + 1] << 8)
pos = pos + 2 + xlen
}
if (flg & 8) != 0 { # FNAME: NUL-terminated
while pos < len and src[pos] != 0 { pos = pos + 1 }
pos = pos + 1
}
if (flg & 16) != 0 { # FCOMMENT: NUL-terminated
while pos < len and src[pos] != 0 { pos = pos + 1 }
pos = pos + 1
}
if (flg & 2) != 0 { pos = pos + 2 } # FHCRC: 2-byte header CRC
if pos + 8 > len { return -1 }
return z_inflate(offset(src, pos), len - pos - 8, out, cap)
}

301
runtime/native/xml.ludic Normal file
View file

@ -0,0 +1,301 @@
# ============================================================================
# xml.ludic — a minimal pure-Ludic XML reader (`Xml.*`), for the TMX/TSX/TX
# subset Tiled emits (Tiled design §0.1, issue #67). It handles exactly what the
# native Tiled formats use: elements, single/double-quoted attributes, nested
# children, text/CDATA content, comments, the `<?xml?>` prolog and `<!DOCTYPE>`,
# and the five predefined entities plus numeric character references. It is NOT
# a validating or namespace-aware parser — best-effort, deterministic, allocation
# the only cost, the same contract as the JSON reader (value.ludic).
#
# ludicc splices this file when a program mentions `Xml.*` (parse.ludic), exactly
# like `Regex.*`/`Value.*`. The tree is plain Ludic over heap records.
#
# A node is one element: its `tag`, parallel attribute `akeys`/`avals`, ordered
# child elements `kids`, and the concatenated character data `text` (the CSV in a
# `<data encoding="csv">` element, or a `<property>` string, lands here).
# ============================================================================
property Xml {
tag: pointer = null # element name ("" for the synthetic empty node)
text: pointer = null # concatenated character data of this element
akeys: []pointer # attribute names
avals: []pointer # attribute values (entity-decoded)
kids: []Xml # child elements, in document order
}
function xml_new(tag: pointer) -> Xml {
let n = new Xml
n.tag = tag
n.text = ""
n.akeys = new []pointer
n.avals = new []pointer
n.kids = new []Xml
return n
}
# --- accessors --------------------------------------------------------------
function xml_tag(n: Xml) -> pointer { if n.tag == null { return "" }; return n.tag }
function xml_text(n: Xml) -> pointer { if n.text == null { return "" }; return n.text }
function xml_child_count(n: Xml) -> int { return len(n.kids) }
function xml_child(n: Xml, i: int) -> Xml {
if i < 0 or i >= len(n.kids) { return xml_new("") }
return n.kids[i]
}
function xml_attr_count(n: Xml) -> int { return len(n.akeys) }
function xml_has(n: Xml, key: pointer) -> int {
var i = 0
while i < len(n.akeys) { if n.akeys[i] == key { return 1 }; i = i + 1 }
return 0
}
function xml_attr(n: Xml, key: pointer) -> pointer {
var i = 0
while i < len(n.akeys) { if n.akeys[i] == key { return n.avals[i] }; i = i + 1 }
return ""
}
# attribute as an integer (decimal, optional leading '-'); `dflt` when absent.
function xml_attr_int(n: Xml, key: pointer, dflt: int) -> int {
if xml_has(n, key) == 0 { return dflt }
return xml_atoi(xml_attr(n, key))
}
# the first direct child named `tag`, or the synthetic empty node if none.
function xml_find(n: Xml, tag: pointer) -> Xml {
var i = 0
while i < len(n.kids) { if n.kids[i].tag == tag { return n.kids[i] }; i = i + 1 }
return xml_new("")
}
# count direct children named `tag`.
function xml_count(n: Xml, tag: pointer) -> int {
var c = 0
var i = 0
while i < len(n.kids) { if n.kids[i].tag == tag { c = c + 1 }; i = i + 1 }
return c
}
# parse a signed decimal integer prefix of `s` (stops at the first non-digit).
function xml_atoi(s: pointer) -> int {
var i = 0
let n = len(s)
var neg = 0
if i < n and s[i] == 45 { neg = 1; i = i + 1 } # '-'
var v = 0
while i < n and s[i] >= 48 and s[i] <= 57 {
v = v * 10 + (s[i] - 48)
i = i + 1
}
if neg != 0 { return 0 - v }
return v
}
# --- entity decoding --------------------------------------------------------
# expand the five predefined entities and &#NN; / &#xHH; numeric references in a
# raw run. Only bytes 0..255 of a character reference are emitted (Ludic strings
# are byte strings); a code point above that is written as its low byte, which is
# ample for the ASCII/Latin-1 text Tiled attributes carry.
function xml_unescape(s: pointer) -> pointer {
# fast path: no '&' means nothing to expand
var k = 0
let m = len(s)
var amp = 0
while k < m { if s[k] == 38 { amp = 1; k = m } else { k = k + 1 } }
if amp == 0 { return s }
var out = ""
var i = 0
while i < m {
let c = s[i]
if c != 38 { # not '&'
# copy the run up to the next '&' in one slice
var j = i
while j < m and s[j] != 38 { j = j + 1 }
out = out + s[i..j]
i = j
} else {
# find the ';'
var j = i + 1
while j < m and s[j] != 59 { j = j + 1 } # ';'
if j >= m { out = out + s[i..m]; i = m }
else {
let ent = s[i + 1..j]
if ent == "amp" { out = out + "&" }
else { if ent == "lt" { out = out + "<" }
else { if ent == "gt" { out = out + ">" }
else { if ent == "quot" { out = out + "\"" }
else { if ent == "apos" { out = out + "'" }
else {
if len(ent) >= 2 and ent[0] == 35 { # '#' numeric reference
var code = 0
if ent[1] == 120 or ent[1] == 88 { # '#x' hex
var h = 2
while h < len(ent) { code = code * 16 + xml_hexval(ent[h]); h = h + 1 }
} else {
var d = 1
while d < len(ent) { code = code * 10 + (ent[d] - 48); d = d + 1 }
}
out = out + xml_byte(code & 255)
} else {
out = out + "&" + ent + ";" # unknown entity, keep literal
}
} } } } }
i = j + 1
}
}
}
return out
}
function xml_hexval(c: int) -> int {
if c >= 48 and c <= 57 { return c - 48 }
if c >= 97 and c <= 102 { return c - 87 }
if c >= 65 and c <= 70 { return c - 55 }
return 0
}
# a one-byte string holding byte value `b` (1..255); "" for 0 (a NUL can't sit in
# a Ludic string). Built by slicing a 256-byte table of every byte value.
var xml_bytetab: pointer = null
function xml_byte(b: int) -> pointer {
if b <= 0 { return "" }
if xml_bytetab == null {
let t = bytes(257)
var i = 0
while i < 256 { t[i] = i + 1; i = i + 1 } # table[i] = byte (i+1), so 0 never appears
t[256] = 0
xml_bytetab = t
}
return xml_bytetab[b - 1..b]
}
# --- parser -----------------------------------------------------------------
property XP { s: pointer = null, i: int = 0, n: int = 0 }
function xp_ws(c: int) -> bool { return c == 32 or c == 9 or c == 10 or c == 13 }
function xp_skip_ws(p: XP) -> void {
while p.i < p.n and xp_ws(p.s[p.i]) { p.i = p.i + 1 }
}
# skip a `<?...?>`, `<!-- ... -->` or `<!DOCTYPE ...>` at the cursor. Returns true
# if it consumed one (cursor on '<').
function xp_skip_misc(p: XP) -> bool {
if p.i + 1 >= p.n or p.s[p.i] != 60 { return false } # '<'
let c = p.s[p.i + 1]
if c == 63 { # '<?' ... '?>'
p.i = p.i + 2
while p.i + 1 < p.n and not (p.s[p.i] == 63 and p.s[p.i + 1] == 62) { p.i = p.i + 1 }
p.i = p.i + 2
return true
}
if c == 33 { # '<!'
if p.i + 3 < p.n and p.s[p.i + 2] == 45 and p.s[p.i + 3] == 45 { # '<!--' comment
p.i = p.i + 4
while p.i + 2 < p.n and not (p.s[p.i] == 45 and p.s[p.i + 1] == 45 and p.s[p.i + 2] == 62) { p.i = p.i + 1 }
p.i = p.i + 3
return true
}
# '<!DOCTYPE ...>' or other declaration — skip to the matching '>'
p.i = p.i + 2
while p.i < p.n and p.s[p.i] != 62 { p.i = p.i + 1 }
p.i = p.i + 1
return true
}
return false
}
# read a name (element or attribute): letters, digits, '_', '-', ':', '.'
function xp_name(p: XP) -> pointer {
let start = p.i
while p.i < p.n {
let c = p.s[p.i]
let ok = (c >= 65 and c <= 90) or (c >= 97 and c <= 122) or (c >= 48 and c <= 57)
if ok or c == 95 or c == 45 or c == 58 or c == 46 { p.i = p.i + 1 }
else { break }
}
return p.s[start..p.i]
}
# parse `key="value"` / `key='value'` attributes into the element node.
function xp_attrs(p: XP, node: Xml) -> void {
while true {
xp_skip_ws(p)
if p.i >= p.n { return }
let c = p.s[p.i]
if c == 62 or c == 47 or c == 63 { return } # '>' '/' '?'
let key = xp_name(p)
if len(key) == 0 { p.i = p.i + 1; continue } # stray char, don't stall
xp_skip_ws(p)
var val = ""
if p.i < p.n and p.s[p.i] == 61 { # '='
p.i = p.i + 1
xp_skip_ws(p)
if p.i < p.n and (p.s[p.i] == 34 or p.s[p.i] == 39) {
let q = p.s[p.i]
p.i = p.i + 1
let start = p.i
while p.i < p.n and p.s[p.i] != q { p.i = p.i + 1 }
val = xml_unescape(p.s[start..p.i])
p.i = p.i + 1 # skip closing quote
}
}
push(node.akeys, key)
push(node.avals, val)
}
}
# parse one element (cursor on its opening '<'). Recurses for children.
function xp_element(p: XP) -> Xml {
p.i = p.i + 1 # skip '<'
let name = xp_name(p)
let node = xml_new(name)
xp_attrs(p, node)
# self-closing '/>'
if p.i < p.n and p.s[p.i] == 47 { # '/'
p.i = p.i + 1
if p.i < p.n and p.s[p.i] == 62 { p.i = p.i + 1 } # '>'
return node
}
if p.i < p.n and p.s[p.i] == 62 { p.i = p.i + 1 } # '>'
# content until the matching close tag
while p.i < p.n {
if p.s[p.i] == 60 { # '<'
if p.i + 1 < p.n and p.s[p.i + 1] == 47 { # '</' close
p.i = p.i + 2
let cn = xp_name(p)
while p.i < p.n and p.s[p.i] != 62 { p.i = p.i + 1 }
p.i = p.i + 1 # skip '>'
return node
}
if p.i + 3 < p.n and p.s[p.i + 1] == 33 and p.s[p.i + 2] == 91 { # '<![' CDATA
# <![CDATA[ ... ]]>
p.i = p.i + 9 # past "<![CDATA["
let start = p.i
while p.i + 2 < p.n and not (p.s[p.i] == 93 and p.s[p.i + 1] == 93 and p.s[p.i + 2] == 62) { p.i = p.i + 1 }
node.text = node.text + p.s[start..p.i]
p.i = p.i + 3 # past "]]>"
} else {
if xp_skip_misc(p) { } # comment / PI inside content
else { push(node.kids, xp_element(p)) }
}
} else {
# character data run up to the next '<'
let start = p.i
while p.i < p.n and p.s[p.i] != 60 { p.i = p.i + 1 }
node.text = node.text + xml_unescape(p.s[start..p.i])
}
}
return node
}
# parse a whole document -> its root element (or the synthetic empty node).
function xml_parse(s: pointer) -> Xml {
let p = new XP
p.s = s; p.i = 0; p.n = len(s)
while p.i < p.n {
xp_skip_ws(p)
if p.i >= p.n { break }
if p.s[p.i] == 60 { # '<'
if xp_skip_misc(p) { } # prolog / comment / doctype
else { return xp_element(p) }
} else { p.i = p.i + 1 }
}
return xml_new("")
}

View file

@ -586,6 +586,27 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "encode") { bare = "json_encode"; push(labels, "value") }
if (meth == "parse") { bare = "json_parse"; push(labels, "text") }
}
# Xml.* — the minimal XML reader (runtime/native/xml.ludic, spliced on demand).
# parse returns an Xml node; the accessors read tag/text/attributes/children.
if (ns == "Xml") {
if (meth == "parse") { bare = "xml_parse"; push(labels, "text") }
if (meth == "tag") { bare = "xml_tag"; push(labels, "node") }
if (meth == "text") { bare = "xml_text"; push(labels, "node") }
if (meth == "attr") { bare = "xml_attr"; push(labels, "node"); push(labels, "key") }
if (meth == "attr_int") { bare = "xml_attr_int"; push(labels, "node"); push(labels, "key"); push(labels, "dflt") }
if (meth == "has") { bare = "xml_has"; push(labels, "node"); push(labels, "key") }
if (meth == "attr_count") { bare = "xml_attr_count"; push(labels, "node") }
if (meth == "child_count") { bare = "xml_child_count"; push(labels, "node") }
if (meth == "child") { bare = "xml_child"; push(labels, "node"); push(labels, "index") }
if (meth == "find") { bare = "xml_find"; push(labels, "node"); push(labels, "tag") }
if (meth == "count") { bare = "xml_count"; push(labels, "node"); push(labels, "tag") }
}
# Base64.* — standard base64 codec (runtime/native/base64.ludic, spliced on
# demand). decode/encode round-trip through NUL-terminated strings.
if (ns == "Base64") {
if (meth == "decode") { bare = "base64_decode"; push(labels, "src") }
if (meth == "encode") { bare = "base64_encode"; push(labels, "src") }
}
# #62: a package-provided namespace (declared with @Namespace(Foo)) that none
# of the hardcoded core blocks matched — alias Foo.method to the bare function
# foo_method (positional args), the same generic path the core aliases use.

View file

@ -183,6 +183,9 @@ function p_postfix() -> Node {
if e.a.kind == E_ID and e.a.s == "Reflect" { g_uses_reflect = true } # force-emit the reflection ABI (Reflect.* reads the world schema)
if e.a.kind == E_ID and e.a.s == "Light" { g_uses_light = true } # splice the 2D light-accumulation pass on demand
if e.a.kind == E_ID and (e.a.s == "Value" or e.a.s == "Json") { g_uses_value = true } # splice the value tree + JSON on demand (#44)
if e.a.kind == E_ID and e.a.s == "Xml" { g_uses_xml = true } # splice the XML reader on demand (Tiled #67)
if e.a.kind == E_ID and e.a.s == "Base64" { g_uses_base64 = true } # splice base64 codec + inflate on demand (Tiled #67)
if e.a.kind == E_ID and e.a.s == "Tiled" { g_uses_tiled = true } # splice the Tiled map runtime on demand (#69)
if e.a.kind == E_ID and e.a.s == "Reflect" and (e.s == "serialize" or e.s == "apply") { g_uses_value = true; g_uses_reflect_io = true } # Reflect.serialize/apply -> value tree + world table
# Input.* action-map / record-replay methods (#7) -> splice input.ludic.
# Input.key stays bare (no runtime), so gate on the new methods only.
@ -433,6 +436,9 @@ var g_uses_query: bool = false # a program mentioned Query.* -> splice the qu
var g_uses_reflect: bool = false # a program mentioned Reflect.* -> force-emit the reflection ABI
var g_uses_light: bool = false # a program mentioned Light.* -> splice the 2D light pass
var g_uses_value: bool = false # Value.*/Json.*/Reflect.serialize -> splice the value tree + JSON (#44)
var g_uses_xml: bool = false # Xml.* -> splice the XML reader (Tiled #67)
var g_uses_base64: bool = false # Base64.* -> splice the base64 codec + inflate (Tiled #67)
var g_uses_tiled: bool = false # Tiled.* -> splice the Tiled map runtime (#69)
var g_uses_reflect_io: bool = false # Reflect.serialize/apply -> splice the reflection serializer
var g_uses_esys: bool = false # an engine-owned system component (SpriteAnim/Motion/Light2D) is declared -> splice systems.ludic + force the reflection ABI
var g_uses_input: bool = false # a program used Input.bind/down/poll/… (action maps + record/replay) -> splice input.ludic
@ -712,6 +718,34 @@ function maybe_splice_runtime() -> void {
do_import("runtime/native/value.ludic")
cur_dir = saved
}
# Xml.* (#67) — the minimal pure-Ludic XML reader for the TMX/TSX/TX subset.
# Self-contained (string/slice ops only), so it works in a plain tool too.
if g_uses_xml {
cur_dir = ""
do_import("runtime/native/xml.ludic")
cur_dir = saved
}
# Base64.* (#67) — base64 codec; pull in the DEFLATE inflater alongside it, so
# a plain tool can run the full base64 -> zlib/gzip decode chain (inflate.ludic
# is self-contained; do_import dedupes when a game already linked it via core).
if g_uses_base64 {
cur_dir = ""
do_import("runtime/native/inflate.ludic")
do_import("runtime/native/base64.ludic")
cur_dir = saved
}
# Tiled.* (#69) — the Tiled map runtime: model, GID resolver, loader, draw. It
# reads/writes the tilemap + framebuffer, so it links against core.ludic, and
# it consumes the XML/base64/inflate primitives above.
if g_uses_tiled {
cur_dir = ""
do_import("runtime/native/core.ludic")
do_import("runtime/native/xml.ludic")
do_import("runtime/native/base64.ludic")
do_import("runtime/native/value.ludic")
do_import("runtime/native/tiled.ludic")
cur_dir = saved
}
# Reflect.serialize/apply add the reflection serializer, which stands on both
# the value tree (above) and the world table (force-emitted for Reflect.*).
if g_uses_reflect_io {
@ -821,6 +855,9 @@ function parse_program() -> void {
g_uses_input = false
g_uses_light = false
g_uses_value = false
g_uses_xml = false
g_uses_base64 = false
g_uses_tiled = false
g_uses_reflect_io = false
g_tests = new []Node
g_mod_sys_fn = new []pointer

File diff suppressed because it is too large Load diff

View file

@ -664,5 +664,22 @@
"grid-line_of_sight",
"grid-flood",
"grid-a_star"
],
"xml": [
"xml-parse",
"xml-tag",
"xml-text",
"xml-attr",
"xml-attr_int",
"xml-has",
"xml-attr_count",
"xml-child_count",
"xml-child",
"xml-find",
"xml-count"
],
"base64": [
"base64-decode",
"base64-encode"
]
}

View file

@ -205,6 +205,7 @@ function cmd_test() -> int {
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)")
feat_case("library/serialize", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "serialize.ludic (Value tree + Json encode/parse + Reflect.serialize/apply — bit-exact save/load; issue #44)")
feat_case("library/tiled_p0", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21", "tiled_p0.ludic (Tiled P0: Xml reader + Base64 decode/encode + gzip framing over inflate; issue #67)")
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)")
feat_case("library/light_tiers", "", "1 1 1 1 1 1 1 1 1", "light_tiers.ludic (Light spot/falloff/soft/gel/normal/time_of_day — render-quality tiers 3-4; issue #49)")